commit 5a3a6357cf049c97db1b0fbce626e91ab27ec097 Author: gibbyDev Date: Sun Jul 12 20:26:16 2026 -0400 I am batman diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..5a3c060 --- /dev/null +++ b/.air.toml @@ -0,0 +1,46 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./main" + cmd = "make build" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.github/instructions/00-main.md b/.github/instructions/00-main.md new file mode 100644 index 0000000..1f983a5 --- /dev/null +++ b/.github/instructions/00-main.md @@ -0,0 +1,5 @@ +# Backend Instruction Entry Point (Do Not Edit) + +1. Read the user's prompt input completely before taking action. +2. Act as a backend and database engineer for this repository's architecture. +3. Update only the scoped instruction files in this directory when requested; do not modify this file. diff --git a/.github/instructions/api.md b/.github/instructions/api.md new file mode 100644 index 0000000..f7f0f7a --- /dev/null +++ b/.github/instructions/api.md @@ -0,0 +1,16 @@ +# API Instructions + +## Scope +- Applies to Fiber handlers and route registration under `internal/handlers` and `internal/server/routes.go`. + +## Rules +- Keep API contracts stable; if changing request/response shape, document backward compatibility in PR notes. +- Prefer snake_case JSON request fields for backend payloads and accept legacy aliases only when needed. +- Validate required fields explicitly in handlers and return clear `400` errors with actionable messages. +- Return `404` for missing resources, `500` only for unexpected server/database failures. +- Keep route naming consistent (`/resource`, `/resource/:id`) and group related routes in `routes.go`. + +## Implementation Notes +- Use `database.DB()` directly in handlers unless a service layer exists for that feature. +- Preload only required relations for performance-sensitive list endpoints. +- Keep list endpoints sorted deterministically (e.g., `position asc, id asc`). diff --git a/.github/instructions/containerization.md b/.github/instructions/containerization.md new file mode 100644 index 0000000..803d7ad --- /dev/null +++ b/.github/instructions/containerization.md @@ -0,0 +1,15 @@ +# Containerization Instructions + +## Scope +- Applies to Dockerfiles, `docker-compose.yml`, and make targets that run services. + +## Rules +- Keep service startup deterministic: API, chat, webrtc, and seed images should build independently. +- Avoid embedding secrets in Dockerfiles; rely on `.env` and compose environment wiring. +- Preserve fast local iteration paths (`dev-local`, seed commands) when changing build contexts. +- Keep images minimal and purpose-specific (`Dockerfile`, `Dockerfile.chat`, `Dockerfile.webrtc`, `Dockerfile.seed`). +- If ports or health behavior change, update compose and README usage notes together. + +## Operational Notes +- Prefer non-destructive changes to compose volumes and DB state in dev. +- When build/runtime differs between local and CI, document the reason in PR notes. diff --git a/.github/instructions/database.md b/.github/instructions/database.md new file mode 100644 index 0000000..c5466ae --- /dev/null +++ b/.github/instructions/database.md @@ -0,0 +1,15 @@ +# Database Instructions + +## Scope +- Applies to GORM models in `internal/models` and migration behavior in `internal/database/database.go`. + +## Rules +- Define explicit foreign keys for non-trivial relations (`foreignKey`, `references`) to avoid inference errors. +- Avoid introducing `NOT NULL` constraints on existing populated tables without a backfill plan. +- Keep `AutoMigrate` additive-first; prefer safe schema transitions over breaking changes. +- Use deterministic ordering fields (`position`) for UI-driven entities (kanban sections/tasks). +- Ensure seed data aligns with current schema and relation requirements. + +## Migration Safety +- For legacy data, introduce nullable columns first, backfill, then tighten constraints in a later step. +- Never assume empty tables in shared dev environments. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15a0bd2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with "go test -c" +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +tmp/ + +# IDE specific files +.vscode +.idea + +# .env file +.env + +# Project build +main +*templ.go + +gitmvp.md +TODO.md + +Dockerfile +Dockerfile.* + +# OS X generated file +.DS_Store + +go.mod +go.sum + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..46014ad --- /dev/null +++ b/Makefile @@ -0,0 +1,89 @@ +# Simple Makefile for a Go project + +# Build the application +all: build test + +build: + @echo "Building..." + + + @go build -o main cmd/api/main.go + +# Run the application +run: + @go run cmd/api/main.go +# Create DB container +docker-run: + @if docker compose up --build 2>/dev/null; then \ + : ; \ + else \ + echo "Falling back to Docker Compose V1"; \ + docker compose up --build; \ + fi + +# Shutdown DB container +docker-down: + @if docker compose down 2>/dev/null; then \ + : ; \ + else \ + echo "Falling back to Docker Compose V1"; \ + docker compose down; \ + fi + +# Test the application +test: + @echo "Testing..." + @go test ./... -v +# Integrations Tests for the application +itest: + @echo "Running integration tests..." + @go test ./internal/database -v + +# Clean the binary +clean: + @echo "Cleaning..." + @rm -f main + +# Live Reload +watch: + @if command -v air > /dev/null; then \ + air; \ + echo "Watching...";\ + else \ + read -p "Go's 'air' is not installed on your machine. Do you want to install it? [Y/n] " choice; \ + if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \ + go install github.com/air-verse/air@latest; \ + air; \ + echo "Watching...";\ + else \ + echo "You chose not to install air. Exiting..."; \ + exit 1; \ + fi; \ + fi + +# Start only the PostgreSQL container +db-up: + @if docker compose up -d psql_bp 2>/dev/null; then \ + : ; \ + else \ + echo "Falling back to Docker Compose V1"; \ + docker-compose up -d psql_bp; \ + fi + +# Stop only the PostgreSQL container +db-down: + @if docker compose stop psql_bp 2>/dev/null; then \ + : ; \ + else \ + echo "Falling back to Docker Compose V1"; \ + docker-compose stop psql_bp; \ + fi + +# Run DB in Docker and API locally with Air +dev-local: + @$(MAKE) db-up + @$(MAKE) watch +seed: + @go run cmd/seed/main.go + +.PHONY: all build run test clean watch docker-run docker-down db-up db-down dev-local itest seed diff --git a/README.md b/README.md new file mode 100644 index 0000000..6bba9ce --- /dev/null +++ b/README.md @@ -0,0 +1,162 @@ +# OpsMastery.v5 + +OpsMastery.v5 is a modular, production-ready platform for real-time chat, video/audio calling (WebRTC), and RESTful business operations. It is built in Go, uses JWT authentication, and is designed for easy integration with modern frontends (e.g., Next.js). + +## Project Structure + +``` +OpsMastery.v5/ +├── cmd/ +│ ├── api/ # Main REST API server (users, tickets, auth, etc.) +│ ├── chat-service/ # Real-time chat microservice (WebSocket) +│ └── webrtc-service/ # WebRTC signaling microservice (WebSocket) +├── internal/ +│ ├── chat_service/ # Chat logic +│ ├── webrtc_service/ # WebRTC signaling logic +│ ├── handlers/ # REST API handlers +│ ├── models/ # Data models +│ ├── database/ # DB connection and migrations +│ ├── middleware/ # JWT and role middleware +│ ├── server/ # REST API server setup +│ └── utils/ # Utility functions (JWT, email, etc.) +├── docker-compose.yml # Multi-service orchestration +├── Dockerfile* # Dockerfiles for each service +├── go.mod, go.sum # Go module files +└── README.md +``` + +## Services Overview + +### 1. REST API (`cmd/api`) +- Handles authentication, user management, tickets, and other business logic. +- JWT-secured endpoints. +- Integrates with PostgreSQL. + +### 2. Chat Service (`cmd/chat-service`) +- Real-time chat via WebSocket. +- JWT authentication for all connections. +- Broadcasts messages to all connected clients. +- Persists chat messages in the database. + +### 3. WebRTC Signaling Service (`cmd/webrtc-service`) +- Handles signaling for video/audio calls (SDP/ICE exchange). +- JWT authentication for all connections. +- Designed for integration with WebRTC clients (e.g., browser, mobile). + +## Frontend Integration + +- Designed to be consumed by any modern frontend (e.g., Next.js). +- REST API: Use `fetch`/`axios` with JWT in cookies or headers. +- Chat/WebRTC: Connect via WebSocket with JWT as a query param. + +## Getting Started + +### Prerequisites +- [Go](https://golang.org/doc/install) >= 1.20 +- [Docker](https://docs.docker.com/get-docker/) +- [Node.js](https://nodejs.org/) (for frontend, optional) + +### Clone the Repository + +```sh +git clone https://github.com/gibbyDev/OpsMastery.v5.git +cd OpsMastery.v5 +``` + +### Running with Docker Compose + +```sh +docker compose up --build +``` +- This will start the REST API, chat service, WebRTC signaling service, and PostgreSQL database. +- Services are exposed on: + - REST API: `http://localhost:8080` + - Chat: `ws://localhost:5000/ws` + - WebRTC: `ws://localhost:4000/ws` + +### Running Locally (without Docker) + +1. Start PostgreSQL (see `docker-compose.yml` for env vars). +2. Run each service in a separate terminal: + ```sh + go run cmd/api/main.go + go run cmd/chat-service/main.go + go run cmd/webrtc-service/main.go + ``` + +### Environment Variables +- See `.env.example` or `docker-compose.yml` for required variables (DB connection, JWT secret, etc.). + +## Development & Testing + +- All code is in Go, organized for easy extension. +- Unit and integration tests are in `internal/*/tests`. +- Frontend integration examples available upon request. + +## Contributing + +Pull requests and issues are welcome! Please open an issue for major changes. + +## License + +MIT + +## Getting Started + +These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. See deployment for notes on how to deploy the project on a live system. + +## MakeFile + +Run build make command with tests +```bash +make all +``` + +Build the application +```bash +make build +``` + +Run the application +```bash +make run +``` +Create DB container +```bash +make docker-run +``` + +Run API locally with live reload while only starting Postgres in Docker: +```bash +make dev-local +``` + +Shutdown DB Container +```bash +make docker-down +``` + +Stop only the Postgres container: +```bash +make db-down +``` + +DB Integrations Test: +```bash +make itest +``` + +Live reload the application: +```bash +make watch +``` + +Run the test suite: +```bash +make test +``` + +Clean up binary from the last build: +```bash +make clean +``` diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..f11e614 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,74 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "OpsMastery.v5/internal/database" + "OpsMastery.v5/internal/oauth" + "OpsMastery.v5/internal/server" + + _ "github.com/joho/godotenv/autoload" +) + +func gracefulShutdown(fiberServer *server.FiberServer, done chan bool) { + // Create context that listens for the interrupt signal from the OS. + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + // Listen for the interrupt signal. + <-ctx.Done() + + log.Println("shutting down gracefully, press Ctrl+C again to force") + stop() // Allow Ctrl+C to force shutdown + + // The context is used to inform the server it has 5 seconds to finish + // the request it is currently handling + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := fiberServer.ShutdownWithContext(ctx); err != nil { + log.Printf("Server forced to shutdown with error: %v", err) + } + + log.Println("Server exiting") + + // Notify the main goroutine that the shutdown is complete + done <- true +} + +func main() { + // Initialize the database (if not already done elsewhere) + database.Init() + + // Initialize OAuth providers (Google, GitHub, etc.) + oauth.InitProviders() + + server := server.New() + + // Pass the global db instance to RegisterFiberRoutes + server.RegisterFiberRoutes(database.DB()) + + // Create a done channel to signal when the shutdown is complete + done := make(chan bool, 1) + + go func() { + port, _ := strconv.Atoi(os.Getenv("PORT")) + err := server.Listen(fmt.Sprintf(":%d", port)) + if err != nil { + panic(fmt.Sprintf("http server error: %s", err)) + } + }() + + // Run graceful shutdown in a separate goroutine + go gracefulShutdown(server, done) + + // Wait for the graceful shutdown to complete + <-done + log.Println("Graceful shutdown complete.") +} diff --git a/cmd/chat-service/main.go b/cmd/chat-service/main.go new file mode 100644 index 0000000..e19a943 --- /dev/null +++ b/cmd/chat-service/main.go @@ -0,0 +1,7 @@ +package main + +import "OpsMastery.v5/internal/chat_service" + +func main() { + chat_service.Start() +} diff --git a/cmd/seed/main.go b/cmd/seed/main.go new file mode 100644 index 0000000..3e707b9 --- /dev/null +++ b/cmd/seed/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "OpsMastery.v5/dev" + "OpsMastery.v5/internal/database" +) + +func main() { + database.Init() + dev.Seed(database.DB()) +} diff --git a/cmd/webrtc-service/main.go b/cmd/webrtc-service/main.go new file mode 100644 index 0000000..1f15668 --- /dev/null +++ b/cmd/webrtc-service/main.go @@ -0,0 +1,7 @@ +package main + +import "OpsMastery.v5/internal/webrtc_service" + +func main() { + webrtc_service.StartSignalingServer() +} diff --git a/dev/seed.go b/dev/seed.go new file mode 100644 index 0000000..bcdbb1c --- /dev/null +++ b/dev/seed.go @@ -0,0 +1,192 @@ +// File: dev/seed.go +package dev + +import ( + "fmt" + "math/rand" + "time" + + "OpsMastery.v5/internal/models" + "github.com/go-faker/faker/v4" + "gorm.io/gorm" +) + +// Helper to find a user by ID from a slice +func findUserByID(users []models.User, id uint) models.User { + for _, u := range users { + if u.ID == id { + return u + } + } + return models.User{} +} + +// Helper to get n random users from a slice +func randomUsers(users []models.User, n int) []models.User { + if len(users) < n { + return users + } + rand.Shuffle(len(users), func(i, j int) { users[i], users[j] = users[j], users[i] }) + return users[:n] +} + +func Seed(db *gorm.DB) { + rand.Seed(time.Now().UnixNano()) + + fmt.Println("Seeding database...") + + roles := []string{"User", "Admin", "Manager"} + statuses := []string{"Open", "In Progress", "Resolved", "Closed"} + priorities := []string{"Low", "Normal", "High", "Urgent"} + + // Seed Clients + var clients []models.Client + for i := 0; i < 5; i++ { + client := models.Client{ + Name: faker.Name(), + Email: faker.Email(), + Phone: faker.Phonenumber(), + } + db.Create(&client) + clients = append(clients, client) + } + + // Seed Users + var users []models.User + for i := 0; i < 20; i++ { + user := models.User{ + Name: faker.Name(), + Email: faker.Email(), + Password: "hashedpassword123", // Replace with actual hash in production + Role: roles[rand.Intn(len(roles))], + Active: rand.Intn(2) == 1, + Username: faker.Username(), + ClientID: &clients[rand.Intn(len(clients))].ID, + } + db.Create(&user) + users = append(users, user) + } + + // Seed Tickets + var tickets []models.Ticket + for i := 0; i < 10; i++ { + reporter := users[rand.Intn(len(users))] + assignee := users[rand.Intn(len(users))] + client := clients[rand.Intn(len(clients))] + ticket := models.Ticket{ + Title: faker.Sentence(), + Description: faker.Paragraph(), + ReporterID: reporter.ID, + AssigneeID: assignee.ID, + ClientID: client.ID, + Status: statuses[rand.Intn(len(statuses))], + Priority: priorities[rand.Intn(len(priorities))], + } + db.Create(&ticket) + tickets = append(tickets, ticket) + } + + // Seed Task Sections + sectionTitles := []string{"Todo", "In Progress", "Done"} + var taskSections []models.TaskSection + for i, title := range sectionTitles { + section := models.TaskSection{ + Title: title, + Position: i, + } + db.Create(§ion) + taskSections = append(taskSections, section) + } + + // Seed Tasks + for i := 0; i < 15; i++ { + section := taskSections[rand.Intn(len(taskSections))] + task := models.Tasks{ + Title: faker.Sentence(), + Description: faker.Paragraph(), + Position: i, + SectionID: section.ID, + } + + db.Create(&task) + } + + // Seed General/Group Chats + for i := 0; i < 5; i++ { + chatUsers := randomUsers(users, rand.Intn(5)+3) // 3-7 users + chat := models.Chat{ + Name: faker.Word() + " Group", + IsPrivate: false, + Users: chatUsers, + } + db.Create(&chat) + + // Seed messages for group chat + for j := 0; j < rand.Intn(10)+5; j++ { + sender := chatUsers[rand.Intn(len(chatUsers))] + msg := models.ChatMessage{ + ChatID: chat.ID, + SenderID: sender.ID, + Content: faker.Sentence(), + SentAt: time.Now().Add(time.Duration(-rand.Intn(1000)) * time.Minute), + } + db.Create(&msg) + } + } + + // Seed Ticket Chats + for _, ticket := range tickets { + // Reporter, Assignee, and 1-2 random watchers + watchers := randomUsers(users, rand.Intn(2)+1) + chatUsers := []models.User{ + findUserByID(users, ticket.ReporterID), + findUserByID(users, ticket.AssigneeID), + } + chatUsers = append(chatUsers, watchers...) + + chat := models.Chat{ + Name: "Ticket Chat", + TicketID: &ticket.ID, + IsPrivate: false, + Users: chatUsers, + } + db.Create(&chat) + + // Seed messages for ticket chat + for j := 0; j < rand.Intn(10)+5; j++ { + sender := chatUsers[rand.Intn(len(chatUsers))] + msg := models.ChatMessage{ + ChatID: chat.ID, + SenderID: sender.ID, + Content: faker.Sentence(), + SentAt: time.Now().Add(time.Duration(-rand.Intn(1000)) * time.Minute), + } + db.Create(&msg) + } + } + + // Seed Private 1:1 Chats + for i := 0; i < 10; i++ { + usersPair := randomUsers(users, 2) + chat := models.Chat{ + Name: usersPair[0].Name + " & " + usersPair[1].Name, + IsPrivate: true, + Users: usersPair, + } + db.Create(&chat) + + // Seed messages for private chat + for j := 0; j < rand.Intn(10)+5; j++ { + sender := usersPair[rand.Intn(2)] + msg := models.ChatMessage{ + ChatID: chat.ID, + SenderID: sender.ID, + Content: faker.Sentence(), + SentAt: time.Now().Add(time.Duration(-rand.Intn(1000)) * time.Minute), + } + db.Create(&msg) + } + } + + fmt.Println("Seeding complete!") +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b34ea9b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,92 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + target: prod + restart: unless-stopped + ports: + - "${PORT}:${PORT}" + environment: + APP_ENV: ${APP_ENV} + PORT: ${PORT} + BLUEPRINT_DB_HOST: psql_bp + BLUEPRINT_DB_PORT: 5432 + BLUEPRINT_DB_DATABASE: ${BLUEPRINT_DB_DATABASE} + BLUEPRINT_DB_USERNAME: ${BLUEPRINT_DB_USERNAME} + BLUEPRINT_DB_PASSWORD: ${BLUEPRINT_DB_PASSWORD} + BLUEPRINT_DB_SCHEMA: ${BLUEPRINT_DB_SCHEMA} + EMAIL_FROM: ${EMAIL_FROM} + EMAIL_PASSWORD: ${EMAIL_PASSWORD} + SMTP_HOST: ${SMTP_HOST} + SMTP_PORT: ${SMTP_PORT} + APP_URL: ${APP_URL} + SESSION_SECRET: ${SESSION_SECRET} + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} + GOOGLE_CALLBACK_URL: ${GOOGLE_CALLBACK_URL} + GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID} + GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET} + GITHUB_CALLBACK_URL: ${GITHUB_CALLBACK_URL} + depends_on: + psql_bp: + condition: service_healthy + networks: + - blueprint + chat-service: + build: + context: . + dockerfile: Dockerfile.chat + ports: + - "5000:5000" + networks: + - blueprint + webrtc-service: + build: + context: . + dockerfile: Dockerfile.webrtc + ports: + - "4000:4000" + networks: + - blueprint + psql_bp: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${BLUEPRINT_DB_DATABASE} + POSTGRES_USER: ${BLUEPRINT_DB_USERNAME} + POSTGRES_PASSWORD: ${BLUEPRINT_DB_PASSWORD} + ports: + - "${BLUEPRINT_DB_PORT}:5432" + volumes: + - psql_volume_bp:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "sh -c 'pg_isready -U ${BLUEPRINT_DB_USERNAME} -d ${BLUEPRINT_DB_DATABASE}'"] + interval: 5s + timeout: 5s + retries: 3 + start_period: 15s + networks: + - blueprint + seed: + build: + context: . + dockerfile: Dockerfile.seed + depends_on: + psql_bp: + condition: service_healthy + networks: + - blueprint + environment: + BLUEPRINT_DB_HOST: psql_bp + BLUEPRINT_DB_PORT: 5432 + BLUEPRINT_DB_DATABASE: ${BLUEPRINT_DB_DATABASE} + BLUEPRINT_DB_USERNAME: ${BLUEPRINT_DB_USERNAME} + BLUEPRINT_DB_PASSWORD: ${BLUEPRINT_DB_PASSWORD} + BLUEPRINT_DB_SCHEMA: ${BLUEPRINT_DB_SCHEMA} + command: ["./seed"] + +volumes: + psql_volume_bp: +networks: + blueprint: diff --git a/internal/chat_service/chat_service.go b/internal/chat_service/chat_service.go new file mode 100644 index 0000000..300dda6 --- /dev/null +++ b/internal/chat_service/chat_service.go @@ -0,0 +1,105 @@ +package chat_service + +import ( + "fmt" + + "OpsMastery.v5/internal/utils" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/websocket/v2" +) + +// Message struct definition +// This struct represents a chat message sent/received via WebSocket. +// It contains the message content and the sender's identifier (email). +type Message struct { + Content string `json:"content"` // The actual message text + Sender string `json:"sender"` // The sender's email or username + // Add more fields as needed (e.g., timestamp, chatId, etc.) +} + +// clients is a map of active WebSocket connections. +// The key is the pointer to the websocket.Conn, the value is a boolean (always true). +// This allows us to keep track of all connected clients for broadcasting messages. +var clients = make(map[*websocket.Conn]bool) + +// broadcastMessage sends a given Message to all connected clients. +// If a client connection fails, it is removed from the clients map. +func broadcastMessage(msg Message) { + for client := range clients { + // Send the message as JSON to the client + err := client.WriteJSON(msg) + if err != nil { + // If sending fails, print error, close connection, and remove client + fmt.Println("Error sending message:", err) + client.Close() + delete(clients, client) + } + } +} + +// Start initializes and runs the chat WebSocket server. +func Start() { + // Create a new Fiber app instance + app := fiber.New() + + // Serve static files from the ./public directory at /public + app.Static("/public", "./public") + + // Middleware for /ws route to validate JWT token before allowing WebSocket upgrade + app.Use("/ws", func(c *fiber.Ctx) error { + // Extract token from query parameters + token := c.Query("token") + if token == "" { + // If no token, reject the request + return c.Status(fiber.StatusUnauthorized).SendString("Missing token") + } + // Validate the JWT token + claims, err := utils.ValidateJWT(token, false) + if err != nil { + // If token is invalid, reject the request + return c.Status(fiber.StatusUnauthorized).SendString("Invalid token") + } + // Store the user's email in Fiber's context locals for later use + c.Locals("userEmail", claims["email"]) + // Proceed to the next handler (WebSocket upgrade) + return c.Next() + }) + + // WebSocket endpoint for chat communication + app.Get("/ws", websocket.New(func(c *websocket.Conn) { + // Retrieve the user's email from context locals + userEmail, _ := c.Locals("userEmail").(string) + // Register the new client connection + clients[c] = true + // Ensure client is removed and connection closed when handler exits + defer func() { + delete(clients, c) + c.Close() + }() + // Main loop: read messages from this client and broadcast to all clients + for { + var msg Message + // Try to read a JSON message from the client + err := c.ReadJSON(&msg) + if err != nil { + // If JSON parsing fails, try to read a raw message (e.g., plain text) + _, rawMsg, readErr := c.ReadMessage() + if readErr != nil { + // If reading fails, log and break the loop (disconnect client) + fmt.Println("WebSocket closed:", readErr) + break + } + // Construct a Message from the raw message and set sender + msg = Message{Content: string(rawMsg), Sender: userEmail} + } else { + // If JSON was parsed, set the sender field + msg.Sender = userEmail + } + // Broadcast the received message to all connected clients + broadcastMessage(msg) + } + })) + + // Start the Fiber server on port 5000 + app.Listen(":5000") +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..a2fdeef --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,70 @@ +package database + +import ( + "log" + "os" + + "OpsMastery.v5/internal/models" + _ "github.com/joho/godotenv/autoload" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +// Global GORM DB instance +// This variable holds the database connection and is shared across the app. +var db *gorm.DB + +// DB returns the global db instance +// Use this function to get the database connection anywhere in your codebase. +func DB() *gorm.DB { + return db +} + +// Init initializes the global db instance and runs automigrate +// This function sets up the database connection using environment variables, +// and automatically migrates all models to ensure the schema is up to date. +func Init() { + // Load environment variables for database connection. + // These should be set in your .env file or container environment. + dbHost := os.Getenv("BLUEPRINT_DB_HOST") // Database host (e.g., psql_bp or localhost) + dbUser := os.Getenv("BLUEPRINT_DB_USERNAME") // Database username + dbPassword := os.Getenv("BLUEPRINT_DB_PASSWORD") // Database password + dbName := os.Getenv("BLUEPRINT_DB_DATABASE") // Database name + dbPort := os.Getenv("BLUEPRINT_DB_PORT") // Database port (usually 5432 for Postgres) + dbSSLMode := "disable" // SSL mode for local/dev; can be set via env for prod + dbSchema := os.Getenv("BLUEPRINT_DB_SCHEMA") // Database schema (e.g., public) + + // Build the Data Source Name (DSN) string for Postgres connection. + // This string contains all connection parameters. + dsn := "host=" + dbHost + + " user=" + dbUser + + " password=" + dbPassword + + " dbname=" + dbName + + " port=" + dbPort + + " sslmode=" + dbSSLMode + + " search_path=" + dbSchema + + // Attempt to open a connection to the database using GORM and the DSN. + var err error + db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + // If connection fails, log the error and exit the application. + log.Fatalf("failed to connect to database: %v", err) + } + + // Automigrate all models. + // This will create or update tables for User, Ticket, Client, Chat, and ChatMessage. + // It ensures your database schema matches your Go models. + if err := db.AutoMigrate( + &models.User{}, + &models.Ticket{}, + &models.TaskSection{}, + &models.Tasks{}, + &models.Client{}, + &models.Chat{}, + &models.ChatMessage{}, + ); err != nil { + // If migration fails, log the error and exit the application. + log.Fatalf("failed to auto migrate models: %v", err) + } +} diff --git a/internal/database/database_test.go b/internal/database/database_test.go new file mode 100644 index 0000000..64c720a --- /dev/null +++ b/internal/database/database_test.go @@ -0,0 +1,100 @@ +package database + +// import ( +// "context" +// "log" +// "testing" +// "time" + +// "github.com/testcontainers/testcontainers-go" +// "github.com/testcontainers/testcontainers-go/modules/postgres" +// "github.com/testcontainers/testcontainers-go/wait" +// ) + +// func mustStartPostgresContainer() (func(context.Context, ...testcontainers.TerminateOption) error, error) { +// var ( +// dbName = "database" +// dbPwd = "password" +// dbUser = "user" +// ) + +// dbContainer, err := postgres.Run( +// context.Background(), +// "postgres:latest", +// postgres.WithDatabase(dbName), +// postgres.WithUsername(dbUser), +// postgres.WithPassword(dbPwd), +// testcontainers.WithWaitStrategy( +// wait.ForLog("database system is ready to accept connections"). +// WithOccurrence(2). +// WithStartupTimeout(5*time.Second)), +// ) +// if err != nil { +// return nil, err +// } + +// database = dbName +// password = dbPwd +// username = dbUser + +// dbHost, err := dbContainer.Host(context.Background()) +// if err != nil { +// return dbContainer.Terminate, err +// } + +// dbPort, err := dbContainer.MappedPort(context.Background(), "5432/tcp") +// if err != nil { +// return dbContainer.Terminate, err +// } + +// host = dbHost +// port = dbPort.Port() + +// return dbContainer.Terminate, err +// } + +// func TestMain(m *testing.M) { +// teardown, err := mustStartPostgresContainer() +// if err != nil { +// log.Fatalf("could not start postgres container: %v", err) +// } + +// m.Run() + +// if teardown != nil && teardown(context.Background()) != nil { +// log.Fatalf("could not teardown postgres container: %v", err) +// } +// } + +// func TestNew(t *testing.T) { +// srv := New() +// if srv == nil { +// t.Fatal("New() returned nil") +// } +// } + +// func TestHealth(t *testing.T) { +// srv := New() + +// stats := srv.Health() + +// if stats["status"] != "up" { +// t.Fatalf("expected status to be up, got %s", stats["status"]) +// } + +// if _, ok := stats["error"]; ok { +// t.Fatalf("expected error not to be present") +// } + +// if stats["message"] != "It's healthy" { +// t.Fatalf("expected message to be 'It's healthy', got %s", stats["message"]) +// } +// } + +// func TestClose(t *testing.T) { +// srv := New() + +// if srv.Close() != nil { +// t.Fatalf("expected Close() to return nil") +// } +// } diff --git a/internal/handlers/authHandler.go b/internal/handlers/authHandler.go new file mode 100644 index 0000000..cb1d238 --- /dev/null +++ b/internal/handlers/authHandler.go @@ -0,0 +1,396 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + "time" + + "OpsMastery.v5/internal/database" + "OpsMastery.v5/internal/models" + "OpsMastery.v5/internal/utils" + "github.com/gofiber/adaptor/v2" + "github.com/gofiber/fiber/v2" + "github.com/markbates/goth/gothic" + "golang.org/x/crypto/bcrypt" +) + +func SignUp(c *fiber.Ctx) error { + var user models.User + var input struct { + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` + Role string `json:"role"` + Username string `json:"username"` + } + + if err := c.BodyParser(&input); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + user.Email = strings.ToLower(input.Email) + user.Password = input.Password + user.Name = strings.ToLower(input.Name) + user.Role = input.Role + user.Username = strings.ToLower(input.Username) + user.Active = false + user.VerificationToken = utils.GenerateRandomToken() + + storedHash, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to hash password"}) + } + user.Password = string(storedHash) + + log.Printf("Hashed password for user %s: %s\n", user.Email, user.Password) + + var existingUser models.User + if err := database.DB().Where("email = ? OR username = ?", user.Email, user.Username).First(&existingUser).Error; err == nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "Email or username already exists"}) + } + + if err := database.DB().Create(&user).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + if err := utils.SendVerificationEmail(user.Email, user.VerificationToken); err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send verification email"}) + } + + return c.Status(http.StatusCreated).JSON(fiber.Map{ + "message": "Registration successful. Please check your email to verify your account.", + }) +} + +func SignIn(c *fiber.Ctx) error { + var userInput struct { + EmailOrUsername string `json:"emailOrUsername"` + Password string `json:"password"` + } + + if err := c.BodyParser(&userInput); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "Invalid input"}) + } + + // Force to lowercase for case-insensitive match + normalizedInput := strings.ToLower(userInput.EmailOrUsername) + + var user models.User + if err := database.DB().Where("LOWER(email) = ? OR LOWER(username) = ?", normalizedInput, normalizedInput).First(&user).Error; err != nil { + return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid email/username or password"}) + } + + if !user.Active { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Please verify your email before signing in", + }) + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(userInput.Password)); err != nil { + return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid email/username or password"}) + } + + // Generate JWTs + accessToken, refreshToken, err := utils.GenerateJWT(user) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "Could not generate tokens"}) + } + + // Set Refresh Token in HttpOnly Cookie + c.Cookie(&fiber.Cookie{ + Name: "refresh_token", + Value: refreshToken, + Expires: time.Now().Add(7 * 24 * time.Hour), + HTTPOnly: true, + Secure: false, + SameSite: "Strict", + Path: "/api/v1/auth/refresh", // restrict usage + }) + + // Return Access Token in response (frontend stores in memory) + return c.Status(http.StatusOK).JSON(fiber.Map{ + "message": "Sign in successful", + "access_token": accessToken, + "user": fiber.Map{ + "id": user.ID, + "email": user.Email, + "name": user.Name, + "role": user.Role, + "username": user.Username, + "address": user.Address, + "phoneNumber": user.PhoneNumber, + "active": user.Active, + }, + }) +} + +func RefreshToken(c *fiber.Ctx) error { + refreshToken := c.Cookies("refresh_token") + if refreshToken == "" { + return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "Refresh token not found"}) + } + + claims, err := utils.ValidateJWT(refreshToken, true) + if err != nil { + return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid refresh token"}) + } + + // Get user from claims + var user models.User + if err := database.DB().First(&user, claims["sub"]).Error; err != nil { + return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "User not found"}) + } + + // Generate new tokens + accessToken, newRefreshToken, err := utils.GenerateJWT(user) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "Could not generate tokens"}) + } + + // Rotate refresh token + c.Cookie(&fiber.Cookie{ + Name: "refresh_token", + Value: newRefreshToken, + Expires: time.Now().Add(7 * 24 * time.Hour), + HTTPOnly: true, + Secure: false, + SameSite: "Strict", + Path: "/api/auth/refresh", + }) + + // Return new Access Token + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "access_token": accessToken, + }) +} + +func SignOut(c *fiber.Ctx) error { + // Invalidate the refresh token cookie + c.Cookie(&fiber.Cookie{ + Name: "refresh_token", + Value: "", + Expires: time.Now().Add(-1 * time.Hour), + HTTPOnly: true, + Secure: false, + SameSite: "Strict", + Path: "/api/auth/refresh", + }) + + return c.Status(http.StatusOK).JSON(fiber.Map{"message": "Successfully signed out"}) +} + +// func VerifyEmail(c *fiber.Ctx) error { +// token := c.Params("token") + +// var user models.User +// if err := database.DB().Where("verification_token = ?", token).First(&user).Error; err != nil { +// return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ +// "error": "Invalid verification token", +// }) +// } + +// user.Active = true +// user.VerificationToken = "" // Clear the token after verification + +// if err := database.DB().Save(&user).Error; err != nil { +// return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ +// "error": "Failed to verify email", +// }) +// } + +// return c.Status(fiber.StatusOK).JSON(fiber.Map{ +// "message": "Email verified successfully", +// }) +// } +func VerifyEmail(c *fiber.Ctx) error { + var body struct { + Token string `json:"token"` + } + + if err := c.BodyParser(&body); err != nil || body.Token == "" { + return c.Redirect("http://localhost:3000/auth/verification-failed", fiber.StatusSeeOther) + } + + var user models.User + if err := database.DB().Where("verification_token = ?", body.Token).First(&user).Error; err != nil { + return c.Redirect("http://localhost:3000/auth/verification-failed", fiber.StatusSeeOther) + } + + user.Active = true + user.VerificationToken = "" + + if err := database.DB().Save(&user).Error; err != nil { + return c.Redirect("http://localhost:3000/auth/verification-failed", fiber.StatusSeeOther) + } + + return c.Redirect("http://localhost:3000/auth/verification-success", fiber.StatusSeeOther) +} + +func RequestPasswordReset(c *fiber.Ctx) error { + var input struct { + Email string `json:"email"` + } + + if err := c.BodyParser(&input); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid input"}) + } + + var user models.User + if err := database.DB().Where("email = ?", input.Email).First(&user).Error; err != nil { + // Don't reveal if email exists or not + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "message": "If your email is registered, you will receive a password reset link", + }) + } + + resetToken := utils.GenerateRandomToken() + user.ResetToken = resetToken + user.ResetTokenExpiry = time.Now().Add(1 * time.Hour) + + // Log the token being set + log.Printf("Setting reset token for user %s: %s", user.Email, resetToken) + + if err := database.DB().Save(&user).Error; err != nil { + log.Printf("Error saving user with reset token: %v", err) + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to process password reset", + }) + } + + // Add this log right before SendPasswordResetEmail + log.Printf("About to send reset email with token: %s", resetToken) + + if err := utils.SendPasswordResetEmail(user.Email, resetToken); err != nil { + log.Printf("Error sending reset email: %v", err) + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to send reset email", + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "message": "If your email is registered, you will receive a password reset link", + }) +} + +func ResetPassword(c *fiber.Ctx) error { + var body struct { + ResetToken string `json:"reset_token"` + NewPassword string `json:"new_password"` + } + + // Log the raw request body + rawBody := string(c.Body()) + log.Printf("Received reset password request body: %s", rawBody) + + if err := c.BodyParser(&body); err != nil { + log.Printf("Error parsing request body: %v", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request body", + }) + } + + log.Printf("Reset token: %s", body.ResetToken) + + // Validate the token + var user models.User + if err := database.DB().Where("reset_token = ?", body.ResetToken).First(&user).Error; err != nil { + log.Printf("No user found with reset token: %s, error: %v", body.ResetToken, err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid reset token", + }) + } + + // Check if token has expired + if user.ResetTokenExpiry.Before(time.Now()) { + log.Printf("Token expired. Expiry: %v, Current time: %v", user.ResetTokenExpiry, time.Now()) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Reset token has expired", + }) + } + + // Hash the new password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost) + if err != nil { + log.Printf("Error hashing password: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to hash password", + }) + } + + // Update user's password and clear reset token + user.Password = string(hashedPassword) + user.ResetToken = "" + user.ResetTokenExpiry = time.Time{} + + if err := database.DB().Save(&user).Error; err != nil { + log.Printf("Error saving user: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to update password", + }) + } + + log.Printf("Password successfully reset for user: %s", user.Email) + return c.JSON(fiber.Map{ + "message": "Password successfully reset", + }) +} + +func validateResetToken(token string) (bool, error) { + var user models.User + if err := database.DB().Where("reset_token = ?", token).First(&user).Error; err != nil { + log.Printf("No user found with reset token: %s, error: %v", token, err) + return false, err + } + + // Check if token has expired + if user.ResetTokenExpiry.Before(time.Now()) { + log.Printf("Token expired. Expiry: %v, Current time: %v", user.ResetTokenExpiry, time.Now()) + return false, fmt.Errorf("reset token has expired") + } + + log.Printf("Token validated successfully for user: %s", user.Email) + return true, nil +} + +// Generic OAuth login handler +func OAuthLogin(c *fiber.Ctx) error { + provider := c.Params("provider") + // Set provider in query for gothic + req := c.Request() + uri := req.URI() + uri.SetQueryString("provider=" + provider) + return adaptor.HTTPHandlerFunc(gothic.BeginAuthHandler)(c) +} + +func OAuthCallback(c *fiber.Ctx) error { + provider := c.Params("provider") + req := c.Request() + req.URI().SetQueryString("provider=" + provider) + + return adaptor.HTTPHandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, err := gothic.CompleteUserAuth(w, r) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + // Check if user with this email already exists + var existingUser models.User + email := strings.ToLower(user.Email) + if err := database.DB().Where("LOWER(email) = ?", email).First(&existingUser).Error; err == nil { + // User exists, do not allow OAuth sign up + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "This email already exists, please sign in.", + }) + return + } + + // ...proceed with creating new user from OAuth profile... + // (your existing logic here) + json.NewEncoder(w).Encode(user) + })(c) +} diff --git a/internal/handlers/chatHandler.go b/internal/handlers/chatHandler.go new file mode 100644 index 0000000..07d05cd --- /dev/null +++ b/internal/handlers/chatHandler.go @@ -0,0 +1,224 @@ +// // filepath: /home/cody/OpsMastery.v5/internal/handlers/chatHandler.go +package handlers + +import ( + "fmt" + "sync" + "time" + + "OpsMastery.v5/internal/database" + "OpsMastery.v5/internal/models" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/websocket/v2" +) + +var ( + clients = make(map[*websocket.Conn]uint) // map connection to user ID + broadcast = make(chan Message) + mu sync.Mutex + chatClients = make(map[uint]map[*websocket.Conn]bool) // chatID -> set of connections +) + +type Message struct { + SenderID uint `json:"sender_id"` + RecipientID uint `json:"recipient_id"` + Content string `json:"content"` +} + +type Chat struct { + ID uint `json:"id"` + Name string `json:"name"` + TicketID *uint `json:"ticket_id,omitempty"` + IsPrivate bool `json:"is_private"` + Users []models.User `json:"users"` + Messages []models.ChatMessage `json:"messages"` +} + +// WebSocket chat endpoint +func ChatWebSocket(c *fiber.Ctx) error { + // Upgrade to WebSocket + if websocket.IsWebSocketUpgrade(c) { + return c.Next() + } + return fiber.ErrUpgradeRequired +} + +func HandleChat(c *websocket.Conn, claims map[string]interface{}) { + senderID, _ := claims["userID"].(uint) + + // Register connection + if chatClients[0] == nil { + chatClients[0] = make(map[*websocket.Conn]bool) + } + chatClients[0][c] = true + defer func() { + delete(chatClients[0], c) + c.Close() + }() + + for { + var msg struct { + Content string `json:"content"` + } + if err := c.ReadJSON(&msg); err != nil { + break + } + + // Save message to DB + chatMsg := models.ChatMessage{ + ChatID: 0, + SenderID: senderID, + Content: msg.Content, + SentAt: time.Now(), + } + database.DB().Create(&chatMsg) + + // Broadcast to all clients in the chat + for client := range chatClients[0] { + client.WriteJSON(chatMsg) + } + } +} + +// Broadcast messages to all clients +func StartBroadcast() { + for { + msg := <-broadcast + mu.Lock() + for conn := range clients { + if err := conn.WriteJSON(msg); err != nil { + fmt.Println("Error broadcasting:", err) + } + } + mu.Unlock() + } +} + +// GET /api/v1/chats?user1=username1&user2=username2 +func GetChatHistory(c *fiber.Ctx) error { + user1 := c.Query("user1") + user2 := c.Query("user2") + var messages []models.ChatMessage + database.DB().Where( + "(sender_id = ? AND recipient_id = ?) OR (sender_id = ? AND recipient_id = ?)", + user1, user2, user2, user1, + ).Order("created_at asc").Find(&messages) + return c.JSON(messages) // Returns [] if no messages exist +} + +// GET /api/v1/chat_partners?user_id=some_user_id +func GetChatPartners(c *fiber.Ctx) error { + userID := c.Query("user_id") + var partners []models.User + database.DB().Raw(` + SELECT DISTINCT u.* + FROM users u + JOIN chat_messages cm + ON (cm.sender_id = u.id OR cm.recipient_id = u.id) + WHERE (cm.sender_id = ? OR cm.recipient_id = ?) AND u.id != ? + `, userID, userID, userID).Scan(&partners) + return c.JSON(partners) +} + +// DELETE /api/v1/chats/delete?user1=username1&user2=username2 +func DeleteChatBetweenUsers(c *fiber.Ctx) error { + user1 := c.Query("user1") + user2 := c.Query("user2") + result := database.DB().Where( + "(sender_id = ? AND recipient_id = ?) OR (sender_id = ? AND recipient_id = ?)", + user1, user2, user2, user1, + ).Delete(&models.ChatMessage{}) + if result.Error != nil { + return c.Status(500).JSON(fiber.Map{"error": result.Error.Error()}) + } + return c.JSON(fiber.Map{"deleted": result.RowsAffected}) +} + +// Create a new chat (group or direct) +func CreateChat(c *fiber.Ctx) error { + var input struct { + Name string `json:"name"` + UserIDs []uint `json:"user_ids"` + IsPrivate bool `json:"is_private"` + TicketID *uint `json:"ticket_id"` + } + if err := c.BodyParser(&input); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid input"}) + } + + chat := models.Chat{ + Name: input.Name, + IsPrivate: input.IsPrivate, + TicketID: input.TicketID, + } + + if err := database.DB().Create(&chat).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Could not create chat"}) + } + + if len(input.UserIDs) > 0 { + var users []models.User + if err := database.DB().Where("id IN ?", input.UserIDs).Find(&users).Error; err == nil { + database.DB().Model(&chat).Association("Users").Append(users) + } + } + + return c.Status(fiber.StatusCreated).JSON(chat) +} + +// Add users to an existing chat +func AddUsersToChat(c *fiber.Ctx) error { + chatID := c.Params("chatId") + var input struct { + UserIDs []uint `json:"user_ids"` + } + if err := c.BodyParser(&input); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid input"}) + } + var chat models.Chat + if err := database.DB().First(&chat, chatID).Error; err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Chat not found"}) + } + var users []models.User + if err := database.DB().Where("id IN ?", input.UserIDs).Find(&users).Error; err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Users not found"}) + } + database.DB().Model(&chat).Association("Users").Append(users) + return c.JSON(fiber.Map{"added": len(users)}) +} + +// Get all chats for a user (for sidebar) +func GetChatsForUser(c *fiber.Ctx) error { + userID := c.Params("userId") + var chats []models.Chat + if err := database.DB().Joins("JOIN chat_users ON chat_users.chat_id = chats.id"). + Where("chat_users.user_id = ?", userID). + Preload("Users"). + Preload("Messages"). + Find(&chats).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Could not fetch chats"}) + } + return c.JSON(chats) +} + +// Get all messages for a chat +func GetChatMessages(c *fiber.Ctx) error { + chatID := c.Params("chatId") + var messages []models.ChatMessage + if err := database.DB().Where("chat_id = ?", chatID).Order("sent_at asc").Find(&messages).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Could not fetch messages"}) + } + return c.JSON(messages) +} + +// Delete a chat (and its messages) +func DeleteChat(c *fiber.Ctx) error { + chatID := c.Params("chatId") + if err := database.DB().Where("chat_id = ?", chatID).Delete(&models.ChatMessage{}).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Could not delete messages"}) + } + if err := database.DB().Delete(&models.Chat{}, chatID).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Could not delete chat"}) + } + return c.JSON(fiber.Map{"deleted": chatID}) +} diff --git a/internal/handlers/taskHandler.go b/internal/handlers/taskHandler.go new file mode 100644 index 0000000..0e94a11 --- /dev/null +++ b/internal/handlers/taskHandler.go @@ -0,0 +1,327 @@ +package handlers + +import ( + "net/http" + "strconv" + "strings" + + "OpsMastery.v5/internal/database" + "OpsMastery.v5/internal/models" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +type sectionPayload struct { + Title string `json:"title"` + TitleAlt string `json:"Title"` + Position *int `json:"position"` + PositionAlt *int `json:"Position"` +} + +type taskPayload struct { + Title string `json:"title"` + TitleAlt string `json:"Title"` + Description string `json:"description"` + DescriptionAlt string `json:"Description"` + Position *int `json:"position"` + PositionAlt *int `json:"Position"` + SectionID *uint `json:"section_id"` + SectionIDAlt *uint `json:"SectionID"` +} + +func (p sectionPayload) normalizedTitle() string { + title := strings.TrimSpace(p.Title) + if title == "" { + title = strings.TrimSpace(p.TitleAlt) + } + return title +} + +func (p sectionPayload) normalizedPosition() int { + if p.Position != nil { + return *p.Position + } + if p.PositionAlt != nil { + return *p.PositionAlt + } + return 0 +} + +func (p taskPayload) normalizedTask() models.Tasks { + title := strings.TrimSpace(p.Title) + if title == "" { + title = strings.TrimSpace(p.TitleAlt) + } + + description := p.Description + if description == "" { + description = p.DescriptionAlt + } + + position := 0 + if p.Position != nil { + position = *p.Position + } else if p.PositionAlt != nil { + position = *p.PositionAlt + } + + sectionID := uint(0) + if p.SectionID != nil { + sectionID = *p.SectionID + } else if p.SectionIDAlt != nil { + sectionID = *p.SectionIDAlt + } + + return models.Tasks{ + Title: title, + Description: description, + Position: position, + SectionID: sectionID, + } +} + +func sectionExists(sectionID uint) (bool, error) { + var count int64 + err := database.DB().Model(&models.TaskSection{}).Where("id = ?", sectionID).Count(&count).Error + if err != nil { + return false, err + } + return count > 0, nil +} + +func CreateTaskSection(c *fiber.Ctx) error { + var payload sectionPayload + if err := c.BodyParser(&payload); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + section := models.TaskSection{ + Title: payload.normalizedTitle(), + Position: payload.normalizedPosition(), + } + + if section.Title == "" { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "title is required"}) + } + + if err := database.DB().Create(§ion).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(http.StatusCreated).JSON(section) +} + +func ListTaskSections(c *fiber.Ctx) error { + var sections []models.TaskSection + err := database.DB(). + Preload("Tasks", func(db *gorm.DB) *gorm.DB { + return db.Order("position asc, id asc") + }). + Order("position asc, id asc"). + Find(§ions).Error + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(sections) +} + +func UpdateTaskSectionByID(c *fiber.Ctx) error { + id := c.Params("id") + sectionID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid section ID"}) + } + + var payload sectionPayload + if err := c.BodyParser(&payload); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + updates := map[string]any{} + if title := payload.normalizedTitle(); title != "" { + updates["title"] = title + } + if payload.Position != nil || payload.PositionAlt != nil { + updates["position"] = payload.normalizedPosition() + } + + if len(updates) == 0 { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "at least one field is required"}) + } + + result := database.DB().Model(&models.TaskSection{}).Where("id = ?", sectionID).Updates(updates) + if result.Error != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": result.Error.Error()}) + } + if result.RowsAffected == 0 { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "Section not found"}) + } + + var section models.TaskSection + if err := database.DB().First(§ion, sectionID).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(section) +} + +func DeleteTaskSectionByID(c *fiber.Ctx) error { + id := c.Params("id") + sectionID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid section ID"}) + } + + result := database.DB().Unscoped().Delete(&models.TaskSection{}, sectionID) + if result.RowsAffected == 0 { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Section not found"}) + } + if result.Error != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": result.Error.Error()}) + } + + return c.Status(http.StatusOK).JSON(fiber.Map{"message": "Section deleted successfully"}) +} + +func CreateTask(c *fiber.Ctx) error { + var payload taskPayload + if err := c.BodyParser(&payload); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + task := payload.normalizedTask() + if task.Title == "" || task.SectionID == 0 { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "title and section_id are required"}) + } + + exists, err := sectionExists(task.SectionID) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + if !exists { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "section not found"}) + } + + if err := database.DB().Create(&task).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(http.StatusCreated).JSON(task) +} + +func ListTasks(c *fiber.Ctx) error { + var tasks []models.Tasks + err := database.DB(). + Preload("Section"). + Order("position asc, id asc"). + Find(&tasks).Error + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(tasks) +} + +func GetTaskByID(c *fiber.Ctx) error { + id := c.Params("id") + var task models.Tasks + + taskID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid task ID"}) + } + + if err := database.DB().Preload("Section").First(&task, taskID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Task not found"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(task) +} + +func DeleteTaskByID(c *fiber.Ctx) error { + id := c.Params("id") + taskID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid task ID"}) + } + + result := database.DB().Unscoped().Delete(&models.Tasks{}, taskID) + if result.RowsAffected == 0 { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Task not found"}) + } + if result.Error != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": result.Error.Error()}) + } + return c.Status(http.StatusOK).JSON(fiber.Map{"message": "Task deleted successfully"}) +} + +func UpdateTaskByID(c *fiber.Ctx) error { + id := c.Params("id") + taskID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid task ID"}) + } + + var payload taskPayload + if err := c.BodyParser(&payload); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + updates := map[string]any{} + title := strings.TrimSpace(payload.Title) + if title == "" { + title = strings.TrimSpace(payload.TitleAlt) + } + if title != "" { + updates["title"] = title + } + + if payload.Description != "" || payload.DescriptionAlt != "" { + description := payload.Description + if description == "" { + description = payload.DescriptionAlt + } + updates["description"] = description + } + + if payload.Position != nil { + updates["position"] = *payload.Position + } else if payload.PositionAlt != nil { + updates["position"] = *payload.PositionAlt + } + + sectionID := uint(0) + if payload.SectionID != nil { + sectionID = *payload.SectionID + } else if payload.SectionIDAlt != nil { + sectionID = *payload.SectionIDAlt + } + if sectionID != 0 { + exists, err := sectionExists(sectionID) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + if !exists { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "section not found"}) + } + updates["section_id"] = sectionID + } + + if len(updates) == 0 { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "at least one field is required"}) + } + + result := database.DB().Model(&models.Tasks{}).Where("id = ?", taskID).Updates(updates) + if result.Error != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": result.Error.Error()}) + } + if result.RowsAffected == 0 { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "Task not found"}) + } + + var task models.Tasks + if err := database.DB().Preload("Section").First(&task, taskID).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(task) +} diff --git a/internal/handlers/ticketHandler.go b/internal/handlers/ticketHandler.go new file mode 100644 index 0000000..9559565 --- /dev/null +++ b/internal/handlers/ticketHandler.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "net/http" + "strconv" + + "OpsMastery.v5/internal/database" + "OpsMastery.v5/internal/models" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +func CreateTicket(c *fiber.Ctx) error { + var ticket models.Ticket + if err := c.BodyParser(&ticket); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + if err := database.DB().Create(&ticket).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(http.StatusCreated).JSON(ticket) +} + +func ListTickets(c *fiber.Ctx) error { + var tickets []models.Ticket + if err := database.DB().Find(&tickets).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(tickets) +} + +func GetTicketByID(c *fiber.Ctx) error { + id := c.Params("id") + var ticket models.Ticket + + ticketID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid ticket ID"}) + } + + if err := database.DB().First(&ticket, ticketID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Ticket not found"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(ticket) +} + +func DeleteTicketByID(c *fiber.Ctx) error { + id := c.Params("id") + ticketID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid ticket ID"}) + } + + result := database.DB().Unscoped().Delete(&models.Ticket{}, ticketID) + if result.RowsAffected == 0 { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Ticket not found"}) + } + if result.Error != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": result.Error.Error()}) + } + return c.Status(http.StatusOK).JSON(fiber.Map{"message": "Ticket deleted successfully"}) +} + +func UpdateTicketByID(c *fiber.Ctx) error { + id := c.Params("id") + ticketID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid ticket ID"}) + } + + var ticket models.Ticket + if err := c.BodyParser(&ticket); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + if err := database.DB().Model(&models.Ticket{}).Where("id = ?", ticketID).Updates(ticket).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(http.StatusOK).JSON(fiber.Map{"message": "Ticket updated successfully"}) +} diff --git a/internal/handlers/userHandler.go b/internal/handlers/userHandler.go new file mode 100644 index 0000000..3e92ab1 --- /dev/null +++ b/internal/handlers/userHandler.go @@ -0,0 +1,191 @@ +package handlers + +import ( + "net/http" + "strconv" + "strings" + + "OpsMastery.v5/internal/database" + "OpsMastery.v5/internal/models" + "github.com/gofiber/fiber/v2" +) + +func ListUsers(c *fiber.Ctx) error { + var users []models.User + if err := database.DB().Unscoped().Find(&users).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(users) +} + +func GetUserByID(c *fiber.Ctx) error { + id := c.Params("id") + + var user models.User + if err := database.DB().First(&user, id).Error; err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"}) + } + return c.JSON(user) +} + +func DeleteUserByID(c *fiber.Ctx) error { + id := c.Params("id") + + userIDParsed, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "Invalid user ID"}) + } + + result := database.DB().Unscoped().Delete(&models.User{}, userIDParsed) + if result.RowsAffected == 0 { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "User not found"}) + } + if result.Error != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": result.Error.Error()}) + } + return c.Status(http.StatusOK).JSON(fiber.Map{"message": "User deleted successfully"}) +} + +func UpdateUserByID(c *fiber.Ctx) error { + id := c.Params("id") + + var user models.User + if err := database.DB().First(&user, id).Error; err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"}) + } + + var input models.User + if err := c.BodyParser(&input); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid input"}) + } + + // Only update fields if present in input + if input.Name != "" { + user.Name = input.Name + } + if input.Username != "" { + user.Username = input.Username + } + if input.Address != "" { + user.Address = input.Address + } + if input.PhoneNumber != "" { + user.PhoneNumber = input.PhoneNumber + } + if input.Role != "" { + user.Role = input.Role + } + if input.Email != "" { + user.Email = input.Email + } + if input.Password != "" { + user.Password = input.Password // Consider hashing here! + } + // ...handle other fields as needed... + + if err := database.DB().Save(&user).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Could not update user"}) + } + + return c.JSON(user) +} + +func SetUserRole(c *fiber.Ctx) error { + id := c.Params("id") + var user models.User + + if err := database.DB().First(&user, id).Error; err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"}) + } + + var input struct { + Role string `json:"role"` + } + if err := c.BodyParser(&input); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid input"}) + } + + user.Role = input.Role + if err := database.DB().Save(&user).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Could not update user role"}) + } + return c.JSON(user) +} + +func GetCurrentUser(c *fiber.Ctx) error { + userID, ok := c.Locals("userID").(uint) + + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "User ID not found"}) + } + + var user models.User + if err := database.DB().First(&user, userID).Error; err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"}) + } + return c.JSON(user) +} + +func UpdateCurrentUser(c *fiber.Ctx) error { + userID, ok := c.Locals("userID").(uint) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "User ID not found"}) + } + + var user models.User + if err := database.DB().First(&user, userID).Error; err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"}) + } + + // Parse form fields using Fiber's API + name := c.FormValue("name") + email := c.FormValue("email") + // Handle file upload (if needed) + fileHeader, err := c.FormFile("avatar") + if err == nil { + // Process the uploaded file + file, err := fileHeader.Open() + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Failed to open uploaded file"}) + } + defer file.Close() + // ...handle file... + } + + // Update user fields + user.Name = name + user.Email = email + + if err := database.DB().Save(&user).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Could not update user"}) + } + return c.JSON(user) +} + +func GetUserProfilePhoto(c *fiber.Ctx) error { + id := c.Params("id") + var user models.User + if err := database.DB().First(&user, id).Error; err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"}) + } + if len(user.ProfilePhoto) == 0 { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Profile photo not found"}) + } + // You may want to detect the image type; here we default to jpeg + c.Set("Content-Type", "image/jpeg") + return c.Send(user.ProfilePhoto) +} + +func SearchUsers(c *fiber.Ctx) error { + q := c.Query("q") + if q == "" { + return c.JSON([]models.User{}) + } + normalized := strings.ToLower(q) + var users []models.User + database.DB().Debug().Where( + "LOWER(username) LIKE ? OR LOWER(email) LIKE ? OR LOWER(name) LIKE ?", + "%"+normalized+"%", "%"+normalized+"%", "%"+normalized+"%", + ).Find(&users) + return c.JSON(users) +} diff --git a/internal/middleware/jwtMiddleware.go b/internal/middleware/jwtMiddleware.go new file mode 100644 index 0000000..80712bc --- /dev/null +++ b/internal/middleware/jwtMiddleware.go @@ -0,0 +1,81 @@ +package middleware + +import ( + "context" + "net/http" + "os" + "strings" + + "OpsMastery.v5/internal/utils" + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v4" +) + +type Claims struct { + UserID string `json:"user_id"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +var ( + AccessTokenSecret = []byte(os.Getenv("JWT_ACCESS_SECRET")) + RefreshTokenSecret = []byte(os.Getenv("JWT_REFRESH_SECRET")) +) + +func ValidateAccessToken(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("access_token") + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + tokenStr := cookie.Value + claims := &Claims{} + + token, err := jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) { + return AccessTokenSecret, nil + }) + + if err != nil || !token.Valid { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), "claims", claims) + next.ServeHTTP(w, r.WithContext(ctx)) + } +} + +func JWTMiddleware(c *fiber.Ctx) error { + authHeader := c.Get("Authorization") + var token string + + if authHeader != "" { + // Extract the token from the "Bearer " format + tokenParts := strings.Split(authHeader, " ") + if len(tokenParts) == 2 && tokenParts[0] == "Bearer" { + token = tokenParts[1] + } + } + + // If no token in header, check cookie + if token == "" { + token = c.Cookies("access_token") + } + + if token == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing token"}) + } + + claims, err := utils.ValidateJWT(token, false) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid token"}) + } + + c.Locals("userID", claims["sub"]) + c.Locals("userRole", claims["role"]) + c.Locals("userEmail", claims["email"]) + + return c.Next() +} diff --git a/internal/middleware/roleMiddleware.go b/internal/middleware/roleMiddleware.go new file mode 100644 index 0000000..21494c0 --- /dev/null +++ b/internal/middleware/roleMiddleware.go @@ -0,0 +1,36 @@ +package middleware + +import ( + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +func OnlyAdmin(db *gorm.DB, fn fiber.Handler) fiber.Handler { + return func(c *fiber.Ctx) error { + role, ok := c.Locals("userRole").(string) + if !ok || role != "Admin" { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + return fn(c) + } +} + +func OnlyModerator(db *gorm.DB, fn fiber.Handler) fiber.Handler { + return func(c *fiber.Ctx) error { + role, ok := c.Locals("userRole").(string) + if !ok || (role != "Moderator" && role != "Admin") { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + return fn(c) + } +} + +func OnlyUser(db *gorm.DB, fn fiber.Handler) fiber.Handler { + return func(c *fiber.Ctx) error { + role, ok := c.Locals("userRole").(string) + if !ok || role != "User" { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + return fn(c) + } +} diff --git a/internal/models/chatModel.go b/internal/models/chatModel.go new file mode 100644 index 0000000..9c6ec75 --- /dev/null +++ b/internal/models/chatModel.go @@ -0,0 +1,33 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type Chat struct { + gorm.Model + Name string + + TicketID *uint + Ticket *Ticket `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` + + IsPrivate bool `gorm:"default:false"` + + Users []User `gorm:"many2many:chat_users;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` + Messages []ChatMessage `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` +} + +type ChatMessage struct { + gorm.Model + ChatID uint + Chat Chat `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` + + SentAt time.Time + + SenderID uint + Sender User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` + + Content string `gorm:"not null"` +} diff --git a/internal/models/clientModel.go b/internal/models/clientModel.go new file mode 100644 index 0000000..e9641b3 --- /dev/null +++ b/internal/models/clientModel.go @@ -0,0 +1,15 @@ +package models + +import ( + "gorm.io/gorm" +) + +type Client struct { + gorm.Model + Name string `gorm:"not null"` + Email string `gorm:"uniqueIndex;not null"` + Phone string + + Users []User + Tickets []Ticket +} diff --git a/internal/models/taskModel.go b/internal/models/taskModel.go new file mode 100644 index 0000000..fe63d27 --- /dev/null +++ b/internal/models/taskModel.go @@ -0,0 +1,21 @@ +package models + +import ( + "gorm.io/gorm" +) + +type TaskSection struct { + gorm.Model + Title string `gorm:"not null" json:"title"` + Position int `gorm:"not null;default:0" json:"position"` + Tasks []Tasks `gorm:"foreignKey:SectionID;references:ID;constraint:OnDelete:CASCADE;" json:"tasks,omitempty"` +} + +type Tasks struct { + gorm.Model + Title string `gorm:"not null" json:"title"` + Description string `json:"description"` + Position int `gorm:"not null;default:0" json:"position"` + SectionID uint `gorm:"index" json:"section_id"` + Section TaskSection `gorm:"foreignKey:SectionID;references:ID;constraint:OnDelete:CASCADE;" json:"section,omitempty"` +} diff --git a/internal/models/ticketModel.go b/internal/models/ticketModel.go new file mode 100644 index 0000000..089996e --- /dev/null +++ b/internal/models/ticketModel.go @@ -0,0 +1,25 @@ +package models + +import ( + "gorm.io/gorm" +) + +type Ticket struct { + gorm.Model + Title string `gorm:"not null"` + Description string + + ReporterID uint + Reporter User `gorm:"foreignKey:ReporterID"` + + AssigneeID uint + Assignee User `gorm:"foreignKey:AssigneeID"` + + ClientID uint + Client Client + + Status string `gorm:"default:'Open'"` // Add this line + Priority string `gorm:"default:'Normal'"` // Add this line + + Chats []Chat +} diff --git a/internal/models/userModel.go b/internal/models/userModel.go new file mode 100644 index 0000000..05f4066 --- /dev/null +++ b/internal/models/userModel.go @@ -0,0 +1,26 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type User struct { + gorm.Model + Email string `gorm:"uniqueIndex;not null"` + Password string `gorm:"not null"` + Name string + Role string `gorm:"default:User"` + Active bool `gorm:"default:true"` + Username string `gorm:"uniqueIndex"` + Address string + PhoneNumber string + ProfilePhoto []byte `gorm:"type:bytea"` + ClientID *uint + Client *Client + + VerificationToken string `json:"-"` + ResetToken string `json:"-"` + ResetTokenExpiry time.Time `json:"-"` +} diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go new file mode 100644 index 0000000..0327521 --- /dev/null +++ b/internal/oauth/oauth.go @@ -0,0 +1,26 @@ +package oauth + +import ( + "os" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/github" + "github.com/markbates/goth/providers/google" +) + +func InitProviders() { + goth.UseProviders( + google.New( + os.Getenv("GOOGLE_CLIENT_ID"), + os.Getenv("GOOGLE_CLIENT_SECRET"), + os.Getenv("GOOGLE_CALLBACK_URL"), + "email", "profile", + ), + github.New( + os.Getenv("GITHUB_CLIENT_ID"), + os.Getenv("GITHUB_CLIENT_SECRET"), + os.Getenv("GITHUB_CALLBACK_URL"), + "user:email", + ), + ) +} diff --git a/internal/server/routes.go b/internal/server/routes.go new file mode 100644 index 0000000..40ec0bb --- /dev/null +++ b/internal/server/routes.go @@ -0,0 +1,115 @@ +package server + +import ( + "OpsMastery.v5/internal/handlers" + "OpsMastery.v5/internal/middleware" + "OpsMastery.v5/internal/webrtc_service" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +func (s *FiberServer) RegisterFiberRoutes(db *gorm.DB) { + api := s.Group("/api/v1") + + // Public routes (no authentication required) + api.Get("/", func(c *fiber.Ctx) error { + return c.SendString("Server is running!") + }) + // Authentication routes + api.Post("/signup", handlers.SignUp) + api.Post("/signin", handlers.SignIn) + api.Get("/verify/:token", handlers.VerifyEmail) + api.Post("/forgot-password", handlers.RequestPasswordReset) + api.Post("/reset-password", handlers.ResetPassword) + + // Public route to get a user's profile photo by ID + api.Get("/users/:id/profile_photo", handlers.GetUserProfilePhoto) + + // OAuth routes (move under /api/v1) + api.Get("/auth/:provider", handlers.OAuthLogin) + api.Get("/auth/:provider/callback", handlers.OAuthCallback) + + // Protected routes (require authentication) + protected := api.Group("") + protected.Use(middleware.JWTMiddleware) + + // Allow all authenticated users to list users + protected.Get("/users", handlers.ListUsers) + protected.Get("/users/search", handlers.SearchUsers) + + // Admin routes + protected.Delete("/users/:id", middleware.OnlyAdmin(db, handlers.DeleteUserByID)) + protected.Put("/users/:id/role", middleware.OnlyAdmin(db, handlers.SetUserRole)) + + // Moderator routes + protected.Get("/users/:id", middleware.OnlyModerator(db, handlers.GetUserByID)) + protected.Put("/users/:id", middleware.OnlyModerator(db, handlers.UpdateUserByID)) + + // User routes + protected.Get("/users/me", middleware.OnlyUser(db, handlers.GetCurrentUser)) + protected.Put("/users/me", middleware.OnlyUser(db, handlers.UpdateCurrentUser)) + + // Other protected routes + protected.Post("/signout", handlers.SignOut) + protected.Post("/auth/refresh", handlers.RefreshToken) + + // Protected routes for tickets + protected.Post("/ticket", handlers.CreateTicket) + protected.Get("/tickets", handlers.ListTickets) + protected.Get("/ticket/:id", handlers.GetTicketByID) + protected.Put("/ticket/:id", handlers.UpdateTicketByID) + protected.Delete("/ticket/:id", handlers.DeleteTicketByID) + + // Protected routes for task sections + protected.Post("/task-sections", handlers.CreateTaskSection) + protected.Get("/task-sections", handlers.ListTaskSections) + protected.Put("/task-section/:id", handlers.UpdateTaskSectionByID) + protected.Delete("/task-section/:id", handlers.DeleteTaskSectionByID) + + // Protected routes for tasks + protected.Post("/tasks", handlers.CreateTask) + protected.Get("/tasks", handlers.ListTasks) + protected.Get("/task/:id", handlers.GetTaskByID) + protected.Put("/task/:id", handlers.UpdateTaskByID) + protected.Delete("/task/:id", handlers.DeleteTaskByID) + + // Protected routes for chat + protected.Get("/chats", handlers.GetChatHistory) + protected.Post("/chats", handlers.CreateChat) + protected.Post("/chats/:chatId/users", handlers.AddUsersToChat) + protected.Get("/chats/user/:userId", handlers.GetChatsForUser) + protected.Get("/chats/:chatId/messages", handlers.GetChatMessages) + protected.Delete("/chats/:chatId", handlers.DeleteChat) + + // WebRTC routes + protected.Post("/webrtc/start", func(c *fiber.Ctx) error { + go webrtc_service.StartSignalingServer() + return c.JSON(fiber.Map{"status": "signaling server started"}) + }) + + // Chat WebSocket route + // protected.Get("/chat/ws", websocket.New(func(c *websocket.Conn) { + // token := c.Query("token") + // if token == "" { + // fmt.Println("WebSocket closed: missing token") + // c.Close() + // return + // } + // claims, err := utils.ValidateJWT(token, false) + // if err != nil { + // fmt.Println("WebSocket closed: invalid token:", err) + // c.Close() + // return + // } + // // Set user info as locals for use in HandleChat + + // handlers.HandleChat(c, claims) + // })) + + api.Options("/chat/ws", func(c *fiber.Ctx) error { + c.Set("Access-Control-Allow-Origin", "http://localhost:3000") + c.Set("Access-Control-Allow-Credentials", "true") + c.Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + return c.SendStatus(fiber.StatusNoContent) + }) +} diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go new file mode 100644 index 0000000..e6956c6 --- /dev/null +++ b/internal/server/routes_test.go @@ -0,0 +1,39 @@ +package server + +// import ( +// "github.com/gofiber/fiber/v2" +// "io" +// "net/http" +// "testing" +// ) + +// func TestHandler(t *testing.T) { +// // Create a Fiber app for testing +// app := fiber.New() +// // Inject the Fiber app into the server +// s := &FiberServer{App: app} +// // Define a route in the Fiber app +// app.Get("/", s.HelloWorldHandler) +// // Create a test HTTP request +// req, err := http.NewRequest("GET", "/", nil) +// if err != nil { +// t.Fatalf("error creating request. Err: %v", err) +// } +// // Perform the request +// resp, err := app.Test(req) +// if err != nil { +// t.Fatalf("error making request to server. Err: %v", err) +// } +// // Your test assertions... +// if resp.StatusCode != http.StatusOK { +// t.Errorf("expected status OK; got %v", resp.Status) +// } +// expected := "{\"message\":\"Hello World\"}" +// body, err := io.ReadAll(resp.Body) +// if err != nil { +// t.Fatalf("error reading response body. Err: %v", err) +// } +// if expected != string(body) { +// t.Errorf("expected response body to be %v; got %v", expected, string(body)) +// } +// } diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..2ec5f5b --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,30 @@ +package server + +import ( + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" +) + +type FiberServer struct { + *fiber.App +} + +func New() *FiberServer { + server := &FiberServer{ + App: fiber.New(fiber.Config{ + ServerHeader: "OpsMastery.v5", + AppName: "OpsMastery.v5", + }), + } + + // Correct CORS origin to allow your frontend domain + server.App.Use(cors.New(cors.Config{ + AllowOrigins: "http://localhost:3000", + AllowMethods: "GET,POST,PUT,DELETE,OPTIONS,PATCH", + AllowHeaders: "Accept,Authorization,Content-Type", + AllowCredentials: true, // allow credentials for cookies/auth + MaxAge: 300, + })) + + return server +} diff --git a/internal/utils/emailUtils.go b/internal/utils/emailUtils.go new file mode 100644 index 0000000..906b4f5 --- /dev/null +++ b/internal/utils/emailUtils.go @@ -0,0 +1,85 @@ +package utils + +import ( + "fmt" + "log" + "net/smtp" + "os" + + _ "github.com/joho/godotenv/autoload" +) + +func SendVerificationEmail(to string, token string) error { + from := os.Getenv("EMAIL_FROM") + password := os.Getenv("EMAIL_PASSWORD") + smtpHost := os.Getenv("SMTP_HOST") + smtpPort := os.Getenv("SMTP_PORT") + + verificationLink := fmt.Sprintf("%s/api/v1/verify/%s", os.Getenv("APP_URL"), token) + + subject := "Verify Your Email" + body := fmt.Sprintf(` + + +

Welcome to OpsMastery!

+

Please verify your email address by clicking the link below:

+ Verify Email +

If you didn't create this account, please ignore this email.

+ + + `, verificationLink) + + message := fmt.Sprintf("To: %s\r\n"+ + "Subject: %s\r\n"+ + "MIME-Version: 1.0\r\n"+ + "Content-Type: text/html; charset=UTF-8\r\n"+ + "\r\n"+ + "%s\r\n", to, subject, body) + + auth := smtp.PlainAuth("", from, password, smtpHost) + addr := fmt.Sprintf("%s:%s", smtpHost, smtpPort) + + err := smtp.SendMail(addr, auth, from, []string{to}, []byte(message)) + if err != nil { + log.Printf("SMTP error: %v", err) // Add this line for more details + } + return err +} + +func SendPasswordResetEmail(to string, token string) error { + from := os.Getenv("EMAIL_FROM") + password := os.Getenv("EMAIL_PASSWORD") + smtpHost := os.Getenv("SMTP_HOST") + smtpPort := os.Getenv("SMTP_PORT") + + //resetLink := fmt.Sprintf("%s/auth/reset-password?reset_token=%s", os.Getenv("FRONTEND_URL"), token) + + resetLink := fmt.Sprintf("http://localhost:3000/auth/reset-password?reset_token=%s", token) + + log.Printf("Generated reset link: %s", resetLink) + + subject := "Reset Your Password" + body := fmt.Sprintf(` + + +

Password Reset Request

+

Click the link below to reset your password:

+ Reset Password +

If you didn't request this, please ignore this email.

+

This link will expire in 1 hour.

+ + + `, resetLink) + + message := fmt.Sprintf("To: %s\r\n"+ + "Subject: %s\r\n"+ + "MIME-Version: 1.0\r\n"+ + "Content-Type: text/html; charset=UTF-8\r\n"+ + "\r\n"+ + "%s\r\n", to, subject, body) + + auth := smtp.PlainAuth("", from, password, smtpHost) + addr := fmt.Sprintf("%s:%s", smtpHost, smtpPort) + + return smtp.SendMail(addr, auth, from, []string{to}, []byte(message)) +} diff --git a/internal/utils/jwtUtils.go b/internal/utils/jwtUtils.go new file mode 100644 index 0000000..17aad84 --- /dev/null +++ b/internal/utils/jwtUtils.go @@ -0,0 +1,63 @@ +package utils + +import ( + "log" + "os" + "time" + + "OpsMastery.v5/internal/models" + "github.com/golang-jwt/jwt/v4" +) + +var ( + accessTokenSecret = []byte(os.Getenv("JWT_ACCESS_SECRET")) + refreshTokenSecret = []byte(os.Getenv("JWT_REFRESH_SECRET")) +) + +func GenerateJWT(user models.User) (string, string, error) { + accessClaims := jwt.MapClaims{ + "sub": user.ID, + "email": user.Email, + "role": user.Role, + "exp": time.Now().Add(time.Minute * 15).Unix(), + } + + accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) + signedAccessToken, err := accessToken.SignedString(accessTokenSecret) + if err != nil { + log.Println("Error generating access token:", err) + return "", "", err + } + + refreshClaims := jwt.MapClaims{ + "sub": user.ID, + "exp": time.Now().Add(time.Hour * 24 * 7).Unix(), + } + + refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) + signedRefreshToken, err := refreshToken.SignedString(refreshTokenSecret) + if err != nil { + log.Println("Error generating refresh token:", err) + return "", "", err + } + + return signedAccessToken, signedRefreshToken, nil +} + +func ValidateJWT(tokenString string, isRefreshToken bool) (jwt.MapClaims, error) { + secret := accessTokenSecret + if isRefreshToken { + secret = refreshTokenSecret + } + + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.NewValidationError("invalid signing method", jwt.ValidationErrorClaimsInvalid) + } + return secret, nil + }) + if err != nil || !token.Valid { + return nil, err + } + return token.Claims.(jwt.MapClaims), nil +} diff --git a/internal/utils/tokenUtils.go b/internal/utils/tokenUtils.go new file mode 100644 index 0000000..9c6528b --- /dev/null +++ b/internal/utils/tokenUtils.go @@ -0,0 +1,15 @@ +package utils + +import ( + "crypto/rand" + "encoding/hex" +) + +func GenerateRandomToken() string { + bytes := make([]byte, 16) // 16 bytes = 128 bits + _, err := rand.Read(bytes) + if err != nil { + panic("Failed to generate random token") + } + return hex.EncodeToString(bytes) +} diff --git a/internal/webrtc_service/signaling_server.go b/internal/webrtc_service/signaling_server.go new file mode 100644 index 0000000..cfc256a --- /dev/null +++ b/internal/webrtc_service/signaling_server.go @@ -0,0 +1,57 @@ +package webrtc_service + +import ( + "fmt" + + "OpsMastery.v5/internal/utils" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/websocket/v2" + "github.com/pion/webrtc/v3" +) + +func StartSignalingServer() { + // Create a new peer connection configuration + config := webrtc.Configuration{ + ICEServers: []webrtc.ICEServer{ + { + URLs: []string{"stun:stun.l.google.com:19302"}, + }, + }, + } + + // Create a new peer connection + peerConnection, err := webrtc.NewPeerConnection(config) + if err != nil { + fmt.Println("Failed to create peer connection:", err) + return + } + + defer peerConnection.Close() + + app := fiber.New() + + app.Use("/ws", func(c *fiber.Ctx) error { + token := c.Query("token") + if token == "" { + return c.Status(fiber.StatusUnauthorized).SendString("Missing token") + } + claims, err := utils.ValidateJWT(token, false) + if err != nil { + return c.Status(fiber.StatusUnauthorized).SendString("Invalid token") + } + c.Locals("userEmail", claims["email"]) + return c.Next() + }) + + app.Get("/ws", websocket.New(func(c *websocket.Conn) { + userEmail, _ := c.Locals("userEmail").(string) + fmt.Println("WebRTC signaling WebSocket connected for user:", userEmail) + // TODO: Implement signaling logic here + // You can use userEmail for user identification + })) + + app.Listen(":4000") + + // Set up signaling handlers (to be implemented) + fmt.Println("Signaling server started") +}