I am batman
This commit is contained in:
46
.air.toml
Normal file
46
.air.toml
Normal 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
5
.github/instructions/00-main.md
vendored
Normal 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
16
.github/instructions/api.md
vendored
Normal 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`).
|
||||||
15
.github/instructions/containerization.md
vendored
Normal file
15
.github/instructions/containerization.md
vendored
Normal 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
15
.github/instructions/database.md
vendored
Normal 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
43
.gitignore
vendored
Normal 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
89
Makefile
Normal 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
162
README.md
Normal 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
74
cmd/api/main.go
Normal 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
7
cmd/chat-service/main.go
Normal 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
11
cmd/seed/main.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"OpsMastery.v5/dev"
|
||||||
|
"OpsMastery.v5/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
database.Init()
|
||||||
|
dev.Seed(database.DB())
|
||||||
|
}
|
||||||
7
cmd/webrtc-service/main.go
Normal file
7
cmd/webrtc-service/main.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "OpsMastery.v5/internal/webrtc_service"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
webrtc_service.StartSignalingServer()
|
||||||
|
}
|
||||||
192
dev/seed.go
Normal file
192
dev/seed.go
Normal 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(§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!")
|
||||||
|
}
|
||||||
92
docker-compose.yml
Normal file
92
docker-compose.yml
Normal 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:
|
||||||
105
internal/chat_service/chat_service.go
Normal file
105
internal/chat_service/chat_service.go
Normal 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")
|
||||||
|
}
|
||||||
70
internal/database/database.go
Normal file
70
internal/database/database.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
100
internal/database/database_test.go
Normal file
100
internal/database/database_test.go
Normal 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")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
396
internal/handlers/authHandler.go
Normal file
396
internal/handlers/authHandler.go
Normal 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)
|
||||||
|
}
|
||||||
224
internal/handlers/chatHandler.go
Normal file
224
internal/handlers/chatHandler.go
Normal 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})
|
||||||
|
}
|
||||||
327
internal/handlers/taskHandler.go
Normal file
327
internal/handlers/taskHandler.go
Normal 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(§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)
|
||||||
|
}
|
||||||
84
internal/handlers/ticketHandler.go
Normal file
84
internal/handlers/ticketHandler.go
Normal 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"})
|
||||||
|
}
|
||||||
191
internal/handlers/userHandler.go
Normal file
191
internal/handlers/userHandler.go
Normal 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)
|
||||||
|
}
|
||||||
81
internal/middleware/jwtMiddleware.go
Normal file
81
internal/middleware/jwtMiddleware.go
Normal 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()
|
||||||
|
}
|
||||||
36
internal/middleware/roleMiddleware.go
Normal file
36
internal/middleware/roleMiddleware.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
internal/models/chatModel.go
Normal file
33
internal/models/chatModel.go
Normal 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"`
|
||||||
|
}
|
||||||
15
internal/models/clientModel.go
Normal file
15
internal/models/clientModel.go
Normal 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
|
||||||
|
}
|
||||||
21
internal/models/taskModel.go
Normal file
21
internal/models/taskModel.go
Normal 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"`
|
||||||
|
}
|
||||||
25
internal/models/ticketModel.go
Normal file
25
internal/models/ticketModel.go
Normal 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
|
||||||
|
}
|
||||||
26
internal/models/userModel.go
Normal file
26
internal/models/userModel.go
Normal 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
26
internal/oauth/oauth.go
Normal 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
115
internal/server/routes.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
39
internal/server/routes_test.go
Normal file
39
internal/server/routes_test.go
Normal 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
30
internal/server/server.go
Normal 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
|
||||||
|
}
|
||||||
85
internal/utils/emailUtils.go
Normal file
85
internal/utils/emailUtils.go
Normal 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))
|
||||||
|
}
|
||||||
63
internal/utils/jwtUtils.go
Normal file
63
internal/utils/jwtUtils.go
Normal 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
|
||||||
|
}
|
||||||
15
internal/utils/tokenUtils.go
Normal file
15
internal/utils/tokenUtils.go
Normal 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)
|
||||||
|
}
|
||||||
57
internal/webrtc_service/signaling_server.go
Normal file
57
internal/webrtc_service/signaling_server.go
Normal 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")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user