I am batman

This commit is contained in:
2026-07-12 20:26:16 -04:00
commit 5a3a6357cf
37 changed files with 2938 additions and 0 deletions

46
.air.toml Normal file
View File

@@ -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

5
.github/instructions/00-main.md vendored Normal file
View File

@@ -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.

16
.github/instructions/api.md vendored Normal file
View File

@@ -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`).

View File

@@ -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.

15
.github/instructions/database.md vendored Normal file
View File

@@ -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.

43
.gitignore vendored Normal file
View File

@@ -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

89
Makefile Normal file
View File

@@ -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

162
README.md Normal file
View File

@@ -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
```

74
cmd/api/main.go Normal file
View File

@@ -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.")
}

7
cmd/chat-service/main.go Normal file
View File

@@ -0,0 +1,7 @@
package main
import "OpsMastery.v5/internal/chat_service"
func main() {
chat_service.Start()
}

11
cmd/seed/main.go Normal file
View File

@@ -0,0 +1,11 @@
package main
import (
"OpsMastery.v5/dev"
"OpsMastery.v5/internal/database"
)
func main() {
database.Init()
dev.Seed(database.DB())
}

View File

@@ -0,0 +1,7 @@
package main
import "OpsMastery.v5/internal/webrtc_service"
func main() {
webrtc_service.StartSignalingServer()
}

192
dev/seed.go Normal file
View File

@@ -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(&section)
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!")
}

92
docker-compose.yml Normal file
View File

@@ -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:

View File

@@ -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")
}

View File

@@ -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)
}
}

View File

@@ -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")
// }
// }

View File

@@ -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)
}

View File

@@ -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})
}

View File

@@ -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(&section).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(&sections).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(&section, 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)
}

View File

@@ -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"})
}

View File

@@ -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)
}

View File

@@ -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 <token>" 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()
}

View File

@@ -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)
}
}

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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:"-"`
}

26
internal/oauth/oauth.go Normal file
View File

@@ -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",
),
)
}

115
internal/server/routes.go Normal file
View File

@@ -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)
})
}

View File

@@ -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))
// }
// }

30
internal/server/server.go Normal file
View File

@@ -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
}

View File

@@ -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(`
<html>
<body>
<h2>Welcome to OpsMastery!</h2>
<p>Please verify your email address by clicking the link below:</p>
<a href="%s">Verify Email</a>
<p>If you didn't create this account, please ignore this email.</p>
</body>
</html>
`, 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(`
<html>
<body>
<h2>Password Reset Request</h2>
<p>Click the link below to reset your password:</p>
<a href="%s">Reset Password</a>
<p>If you didn't request this, please ignore this email.</p>
<p>This link will expire in 1 hour.</p>
</body>
</html>
`, 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))
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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")
}