the beginning of the idiots
This commit is contained in:
17
qwen/go/.env
Normal file
17
qwen/go/.env
Normal file
@@ -0,0 +1,17 @@
|
||||
# Application Configuration
|
||||
PORT=17000
|
||||
DATABASE_URL=postgresql://mohportal:password@db:5432/mohportal
|
||||
REDIS_URL=redis:6379
|
||||
JWT_SECRET=supersecretkeyforjwt
|
||||
|
||||
# OIDC Configuration
|
||||
OIDC_ISSUER=http://keycloak:8080/realms/master
|
||||
OIDC_CLIENT_ID=mohportal-client
|
||||
OIDC_CLIENT_SECRET=mohportal-secret
|
||||
|
||||
# Security Headers
|
||||
SECURE_COOKIES=true
|
||||
ALLOWED_ORIGINS=*
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
48
qwen/go/Dockerfile
Normal file
48
qwen/go/Dockerfile
Normal file
@@ -0,0 +1,48 @@
|
||||
# Use the official Golang image to create a build artifact
|
||||
# This is a multi-stage build pattern
|
||||
FROM golang:1.21-alpine AS builder
|
||||
|
||||
# Install git, ca-certificates and other dependencies needed for Go modules
|
||||
RUN apk update && apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
# Create and change to the app directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go mod files and download dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy local code to the container image
|
||||
COPY . ./
|
||||
|
||||
# Build the binary
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -v -o server
|
||||
|
||||
# Use a Docker multi-stage build to create a lean production image
|
||||
FROM golang:1.21-alpine
|
||||
|
||||
# Install ca-certificates for SSL connections
|
||||
RUN apk --no-cache add ca-certificates
|
||||
|
||||
# Create a non-root user
|
||||
RUN adduser -D -s /bin/sh appuser
|
||||
|
||||
# Copy the pre-built binary file from the previous stage
|
||||
COPY --from=builder /app/server /server
|
||||
|
||||
# Copy necessary files
|
||||
COPY --from=builder /app/static /static
|
||||
COPY --from=builder /app/templates /templates
|
||||
COPY --from=builder /app/.env /app/.env
|
||||
|
||||
# Change ownership of the binary to the non-root user
|
||||
RUN chown appuser:appuser /server
|
||||
|
||||
# Change to the non-root user
|
||||
USER appuser
|
||||
|
||||
# Expose the port
|
||||
EXPOSE 17000
|
||||
|
||||
# Run the server
|
||||
CMD ["/server"]
|
||||
129
qwen/go/README.md
Normal file
129
qwen/go/README.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# MerchantsOfHope.org Recruiting Platform
|
||||
|
||||
This is the official recruiting platform for MerchantsOfHope.org, designed to connect talented professionals with opportunities across TSYS Group's diverse business lines.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The platform implements a multi-tenant architecture to support TSYS Group's dozens of independent business lines, each with complete data isolation. Key features include:
|
||||
|
||||
- Multi-tenant architecture with data isolation
|
||||
- OIDC and social media login support
|
||||
- Job seeker functionality (browse positions, apply, upload resumes)
|
||||
- Job provider functionality (manage positions, applications)
|
||||
- Full accessibility compliance (WCAG 2.1 AA standards)
|
||||
- Security compliance (PCI, GDPR, SOC, FedRAMP)
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- Backend: Go with Gin framework
|
||||
- Database: PostgreSQL with GORM
|
||||
- Authentication: OIDC and OAuth2
|
||||
- Session Management: Redis
|
||||
- Frontend: HTML/CSS/JS with accessibility focus
|
||||
- Containerization: Docker and Docker Compose
|
||||
|
||||
## Security & Compliance
|
||||
|
||||
The platform implements several security measures to ensure compliance with industry standards:
|
||||
|
||||
- PCI DSS compliance for handling any sensitive data
|
||||
- GDPR compliance for EU data protection
|
||||
- SOC 2 compliance for security, availability, and privacy
|
||||
- FedRAMP compliance for government cloud requirements
|
||||
- Content Security Policy (CSP) headers
|
||||
- Rate limiting and audit logging
|
||||
- Secure authentication with OIDC
|
||||
- Data residency controls
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
qwen/go/
|
||||
├── cmd/
|
||||
├── api/
|
||||
├── db/ # Database connection and migrations
|
||||
├── models/ # Data models
|
||||
├── middleware/ # Authentication and authorization
|
||||
├── handlers/ # HTTP request handlers
|
||||
├── services/ # Business logic
|
||||
├── utils/ # Utility functions
|
||||
├── config/ # Configuration management
|
||||
├── security/ # Security controls and compliance
|
||||
├── templates/ # HTML templates
|
||||
├── static/ # Static assets (CSS, JS, images)
|
||||
├── tests/ # Test files
|
||||
├── main.go # Entry point
|
||||
├── go.mod, go.sum # Go modules
|
||||
├── Dockerfile # Container configuration
|
||||
└── docker-compose.yml # Service orchestration
|
||||
```
|
||||
|
||||
## Running the Application
|
||||
|
||||
The application is designed to run in Docker containers. To start the application:
|
||||
|
||||
1. Ensure Docker and Docker Compose are installed
|
||||
2. Navigate to the `qwen/go` directory
|
||||
3. Run `docker-compose up --build`
|
||||
|
||||
The application will be available at `http://localhost:17000`.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /health` - Health check
|
||||
- `POST /api/v1/auth/login` - User login
|
||||
- `POST /api/v1/auth/register` - User registration
|
||||
- `GET /api/v1/positions` - Browse job positions
|
||||
- `POST /api/v1/positions` - Create job position (job providers)
|
||||
- `POST /api/v1/applications` - Apply to position
|
||||
- `POST /api/v1/resumes` - Upload resume
|
||||
|
||||
## Compliance Features
|
||||
|
||||
The platform includes several features to ensure compliance with regulatory requirements:
|
||||
|
||||
### GDPR Compliance
|
||||
- Data residency controls
|
||||
- User consent mechanisms
|
||||
- Right to deletion implementations
|
||||
- Privacy policy integration
|
||||
|
||||
### Security Controls
|
||||
- Role-based access control
|
||||
- API rate limiting
|
||||
- Content security policy
|
||||
- Audit logging
|
||||
- Secure authentication
|
||||
|
||||
### Accessibility
|
||||
- WCAG 2.1 AA compliance
|
||||
- Semantic HTML structure
|
||||
- Proper ARIA labels
|
||||
- Keyboard navigation
|
||||
- Sufficient color contrast
|
||||
|
||||
## Development
|
||||
|
||||
To run tests:
|
||||
```bash
|
||||
go test ./tests/...
|
||||
```
|
||||
|
||||
For local development, you can run the application directly:
|
||||
```bash
|
||||
go run main.go
|
||||
```
|
||||
|
||||
Note: This requires Go 1.21+, PostgreSQL, and Redis to be installed and running locally.
|
||||
|
||||
## Deployment
|
||||
|
||||
The platform is designed for containerized deployment. The docker-compose.yml file includes all necessary services:
|
||||
|
||||
- Application server
|
||||
- PostgreSQL database
|
||||
- Redis for session management
|
||||
- Nginx as reverse proxy
|
||||
- Keycloak for OIDC
|
||||
|
||||
For production deployment, ensure all security configurations are properly set and consider using Kubernetes for orchestration.
|
||||
41
qwen/go/config/config.go
Normal file
41
qwen/go/config/config.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// Config holds the application configuration
|
||||
type Config struct {
|
||||
Port string
|
||||
DatabaseURL string
|
||||
RedisURL string
|
||||
JWTSecret string
|
||||
OIDCIssuer string
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
Audience string
|
||||
Issuer string
|
||||
}
|
||||
|
||||
// LoadConfig loads the application configuration from environment variables
|
||||
func LoadConfig() *Config {
|
||||
return &Config{
|
||||
Port: getEnv("PORT", "17000"),
|
||||
DatabaseURL: getEnv("DATABASE_URL", "postgresql://mohportal:password@localhost:5432/mohportal"),
|
||||
RedisURL: getEnv("REDIS_URL", "localhost:6379"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "supersecretkeyforjwt"),
|
||||
OIDCIssuer: getEnv("OIDC_ISSUER", "http://localhost:8080/realms/master"),
|
||||
OIDCClientID: getEnv("OIDC_CLIENT_ID", "mohportal-client"),
|
||||
OIDCClientSecret: getEnv("OIDC_CLIENT_SECRET", "mohportal-secret"),
|
||||
Audience: getEnv("AUDIENCE", "mohportal-api"),
|
||||
Issuer: getEnv("ISSUER", "mohportal"),
|
||||
}
|
||||
}
|
||||
|
||||
// getEnv gets an environment variable or returns a default value
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
40
qwen/go/db/db.go
Normal file
40
qwen/go/db/db.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"log"
|
||||
"mohportal/models"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
// ConnectDatabase connects to the database and runs migrations
|
||||
func ConnectDatabase(url string) {
|
||||
var err error
|
||||
|
||||
DB, err = gorm.Open(postgres.Open(url), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
err = DB.AutoMigrate(
|
||||
&models.Tenant{},
|
||||
&models.User{},
|
||||
&models.OIDCIdentity{},
|
||||
&models.SocialIdentity{},
|
||||
&models.JobPosition{},
|
||||
&models.Resume{},
|
||||
&models.Application{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to migrate database:", err)
|
||||
}
|
||||
|
||||
log.Println("Database connected and migrated successfully")
|
||||
}
|
||||
90
qwen/go/docker-compose.yml
Normal file
90
qwen/go/docker-compose.yml
Normal file
@@ -0,0 +1,90 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
qwen-go-mohportal:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: qwen-go-mohportal
|
||||
ports:
|
||||
- "17000:17000"
|
||||
environment:
|
||||
- PORT=17000
|
||||
- DATABASE_URL=postgresql://mohportal:password@db:5432/mohportal
|
||||
- REDIS_URL=redis:6379
|
||||
- JWT_SECRET=supersecretkeyforjwt
|
||||
- OIDC_ISSUER=https://auth.merchants-of-hope.org
|
||||
- OIDC_CLIENT_ID=mohportal-client
|
||||
- OIDC_CLIENT_SECRET=mohportal-secret
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
networks:
|
||||
- mohportal-network
|
||||
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
container_name: qwen-go-mohportal-db
|
||||
environment:
|
||||
- POSTGRES_DB=mohportal
|
||||
- POSTGRES_USER=mohportal
|
||||
- POSTGRES_PASSWORD=password
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
ports:
|
||||
- "5432:5432"
|
||||
networks:
|
||||
- mohportal-network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: qwen-go-mohportal-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- mohportal-network
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: qwen-go-mohportal-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||
- ./ssl:/etc/nginx/ssl
|
||||
depends_on:
|
||||
- qwen-go-mohportal
|
||||
networks:
|
||||
- mohportal-network
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:latest
|
||||
container_name: qwen-go-keycloak
|
||||
environment:
|
||||
- KC_DB=postgres
|
||||
- KC_DB_URL=jdbc:postgresql://db:5432/mohportal
|
||||
- KC_DB_USERNAME=mohportal
|
||||
- KC_DB_PASSWORD=password
|
||||
- KC_ADMIN_USERNAME=admin
|
||||
- KC_ADMIN_PASSWORD=admin
|
||||
- KEYCLOAK_ADMIN=admin
|
||||
- KEYCLOAK_ADMIN_PASSWORD=admin
|
||||
command: ["start-dev"]
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
- mohportal-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
mohportal-network:
|
||||
driver: bridge
|
||||
43
qwen/go/go.mod
Normal file
43
qwen/go/go.mod
Normal file
@@ -0,0 +1,43 @@
|
||||
module mohportal
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0
|
||||
github.com/google/uuid v1.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
gorm.io/driver/postgres v1.5.2
|
||||
gorm.io/gorm v1.25.3
|
||||
github.com/coreos/go-oidc/v3 v3.7.0
|
||||
golang.org/x/oauth2 v0.11.0
|
||||
github.com/redis/go-redis/v9 v9.0.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/gorilla/schema v1.2.0 // indirect
|
||||
github.com/jackc/pgx/v5 v5.4.1 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060804-2de6f1c62802 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.12.0 // indirect
|
||||
golang.org/x/net v0.14.0 // indirect
|
||||
golang.org/x/sys v0.11.0 // indirect
|
||||
golang.org/x/text v0.12.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
638
qwen/go/handlers/handlers.go
Normal file
638
qwen/go/handlers/handlers.go
Normal file
@@ -0,0 +1,638 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"mohportal/middleware"
|
||||
"mohportal/models"
|
||||
"mohportal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
tenantService *services.TenantService
|
||||
userService *services.UserService
|
||||
positionService *services.PositionService
|
||||
resumeService *services.ResumeService
|
||||
applicationService *services.ApplicationService
|
||||
)
|
||||
|
||||
// Initialize services
|
||||
func init() {
|
||||
tenantService = &services.TenantService{}
|
||||
userService = &services.UserService{}
|
||||
positionService = &services.PositionService{}
|
||||
resumeService = &services.ResumeService{}
|
||||
applicationService = &services.ApplicationService{}
|
||||
}
|
||||
|
||||
// HealthCheck returns the health status of the application
|
||||
func HealthCheck(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "healthy",
|
||||
"message": "MerchantsOfHope.org recruiting platform is running",
|
||||
"service": "MOH Portal API",
|
||||
})
|
||||
}
|
||||
|
||||
// Tenant Handlers
|
||||
func CreateTenant(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Slug string `json:"slug" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tenant, err := tenantService.CreateTenant(req.Name, req.Slug, req.Description, req.LogoURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, tenant)
|
||||
}
|
||||
|
||||
func GetTenants(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
|
||||
tenants, err := tenantService.GetTenants(limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tenants)
|
||||
}
|
||||
|
||||
func GetTenant(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid tenant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
tenant, err := tenantService.GetTenant(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tenant)
|
||||
}
|
||||
|
||||
func UpdateTenant(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid tenant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tenant, err := tenantService.UpdateTenant(id, req.Name, req.Slug, req.Description, req.LogoURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tenant)
|
||||
}
|
||||
|
||||
func DeleteTenant(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid tenant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := tenantService.DeleteTenant(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Tenant deleted successfully"})
|
||||
}
|
||||
|
||||
// Auth Handlers
|
||||
func Login(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := userService.AuthenticateUser(req.Email, req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT token (this is a simplified example)
|
||||
// In a real application, you'd use the jwt package to create a proper token
|
||||
|
||||
// For now, return user info with a placeholder token
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Login successful",
|
||||
"user": user,
|
||||
"token": "placeholder_token", // In real implementation, return actual JWT
|
||||
})
|
||||
}
|
||||
|
||||
func Register(c *gin.Context) {
|
||||
var req struct {
|
||||
TenantID string `json:"tenant_id" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
FirstName string `json:"first_name" binding:"required"`
|
||||
LastName string `json:"last_name" binding:"required"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role" binding:"required"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, err := uuid.Parse(req.TenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid tenant ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate role
|
||||
if !models.ValidRole(req.Role) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := userService.CreateUser(tenantID, req.Email, req.Username, req.FirstName, req.LastName, req.Phone, req.Role, req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "User registered successfully",
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
func Logout(c *gin.Context) {
|
||||
middleware.LogoutHandler(c) // This will handle the response
|
||||
}
|
||||
|
||||
func Profile(c *gin.Context) {
|
||||
// Get user from context (set by auth middleware)
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
// OIDC/Social Media Login
|
||||
func OIDCLogin(c *gin.Context) {
|
||||
middleware.OIDCLoginHandler(c) // This will redirect the user
|
||||
}
|
||||
|
||||
func OIDCCallback(c *gin.Context) {
|
||||
middleware.OIDCCallbackHandler(c) // This will handle the callback
|
||||
}
|
||||
|
||||
func SocialLogin(c *gin.Context) {
|
||||
middleware.SocialLoginHandler(c) // This will redirect the user
|
||||
}
|
||||
|
||||
func SocialCallback(c *gin.Context) {
|
||||
middleware.SocialCallbackHandler(c) // This will handle the callback
|
||||
}
|
||||
|
||||
// Position Handlers
|
||||
func GetPositions(c *gin.Context) {
|
||||
// Parse query parameters
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
status := c.Query("status")
|
||||
employmentType := c.Query("employment_type")
|
||||
experienceLevel := c.Query("experience_level")
|
||||
location := c.Query("location")
|
||||
|
||||
// Parse tenant ID if provided
|
||||
var tenantID *uuid.UUID
|
||||
if tenantStr := c.Query("tenant_id"); tenantStr != "" {
|
||||
id, err := uuid.Parse(tenantStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid tenant ID"})
|
||||
return
|
||||
}
|
||||
tenantID = &id
|
||||
}
|
||||
|
||||
positions, err := positionService.GetPositions(tenantID, limit, offset, status, employmentType, experienceLevel, location)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, positions)
|
||||
}
|
||||
|
||||
func GetPosition(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid position ID"})
|
||||
return
|
||||
}
|
||||
|
||||
position, err := positionService.GetPosition(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, position)
|
||||
}
|
||||
|
||||
func CreatePosition(c *gin.Context) {
|
||||
// Get user from context
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
userData, ok := user.(models.User)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error getting user data"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description" binding:"required"`
|
||||
Requirements string `json:"requirements"`
|
||||
Location string `json:"location"`
|
||||
EmploymentType string `json:"employment_type" binding:"required"`
|
||||
SalaryMin *float64 `json:"salary_min"`
|
||||
SalaryMax *float64 `json:"salary_max"`
|
||||
ExperienceLevel string `json:"experience_level" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate employment type
|
||||
if !models.ValidEmploymentType(req.EmploymentType) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid employment type"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate experience level
|
||||
if !models.ValidExperienceLevel(req.ExperienceLevel) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid experience level"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate salary range
|
||||
if req.SalaryMin != nil && req.SalaryMax != nil && *req.SalaryMin > *req.SalaryMax {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Minimum salary cannot be greater than maximum salary"})
|
||||
return
|
||||
}
|
||||
|
||||
position, err := positionService.CreatePosition(userData.TenantID, userData.ID, req.Title, req.Description, req.Requirements, req.Location, req.EmploymentType, req.ExperienceLevel, req.SalaryMin, req.SalaryMax)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, position)
|
||||
}
|
||||
|
||||
func UpdatePosition(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid position ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description" binding:"required"`
|
||||
Requirements string `json:"requirements"`
|
||||
Location string `json:"location"`
|
||||
EmploymentType string `json:"employment_type" binding:"required"`
|
||||
SalaryMin *float64 `json:"salary_min"`
|
||||
SalaryMax *float64 `json:"salary_max"`
|
||||
ExperienceLevel string `json:"experience_level" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate employment type
|
||||
if !models.ValidEmploymentType(req.EmploymentType) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid employment type"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate experience level
|
||||
if !models.ValidExperienceLevel(req.ExperienceLevel) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid experience level"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate salary range
|
||||
if req.SalaryMin != nil && req.SalaryMax != nil && *req.SalaryMin > *req.SalaryMax {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Minimum salary cannot be greater than maximum salary"})
|
||||
return
|
||||
}
|
||||
|
||||
position, err := positionService.UpdatePosition(id, req.Title, req.Description, req.Requirements, req.Location, req.EmploymentType, req.ExperienceLevel, req.SalaryMin, req.SalaryMax)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, position)
|
||||
}
|
||||
|
||||
func DeletePosition(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid position ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := positionService.DeletePosition(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Position deleted successfully"})
|
||||
}
|
||||
|
||||
// Application Handlers
|
||||
func GetApplications(c *gin.Context) {
|
||||
// Parse query parameters
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
status := c.Query("status")
|
||||
|
||||
// Parse user ID if provided
|
||||
var userID *uuid.UUID
|
||||
if userStr := c.Query("user_id"); userStr != "" {
|
||||
id, err := uuid.Parse(userStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
userID = &id
|
||||
}
|
||||
|
||||
// Parse position ID if provided
|
||||
var positionID *uuid.UUID
|
||||
if positionStr := c.Query("position_id"); positionStr != "" {
|
||||
id, err := uuid.Parse(positionStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid position ID"})
|
||||
return
|
||||
}
|
||||
positionID = &id
|
||||
}
|
||||
|
||||
applications, err := applicationService.GetApplications(userID, positionID, status, limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, applications)
|
||||
}
|
||||
|
||||
func GetApplication(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid application ID"})
|
||||
return
|
||||
}
|
||||
|
||||
application, err := applicationService.GetApplication(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, application)
|
||||
}
|
||||
|
||||
func CreateApplication(c *gin.Context) {
|
||||
// Get user from context
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
userData, ok := user.(models.User)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error getting user data"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
PositionID string `json:"position_id" binding:"required"`
|
||||
ResumeID string `json:"resume_id"`
|
||||
CoverLetter string `json:"cover_letter"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
positionID, err := uuid.Parse(req.PositionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid position ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var resumeID *uuid.UUID
|
||||
if req.ResumeID != "" {
|
||||
id, err := uuid.Parse(req.ResumeID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid resume ID"})
|
||||
return
|
||||
}
|
||||
resumeID = &id
|
||||
}
|
||||
|
||||
application, err := applicationService.CreateApplication(positionID, userData.ID, resumeID, req.CoverLetter)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, application)
|
||||
}
|
||||
|
||||
func UpdateApplication(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid application ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get reviewer user from context
|
||||
reviewer, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
reviewerData, ok := reviewer.(models.User)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error getting user data"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate status
|
||||
if !models.ValidStatus(req.Status) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status"})
|
||||
return
|
||||
}
|
||||
|
||||
application, err := applicationService.UpdateApplication(id, req.Status, reviewerData.ID, req.Notes)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, application)
|
||||
}
|
||||
|
||||
func DeleteApplication(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid application ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := applicationService.DeleteApplication(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Application deleted successfully"})
|
||||
}
|
||||
|
||||
// Resume Handlers
|
||||
func UploadResume(c *gin.Context) {
|
||||
// Get user from context
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
userData, ok := user.(models.User)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error getting user data"})
|
||||
return
|
||||
}
|
||||
|
||||
title := c.PostForm("title")
|
||||
if title == "" {
|
||||
title = "Resume"
|
||||
}
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "File upload failed"})
|
||||
return
|
||||
}
|
||||
|
||||
resume, err := resumeService.UploadResume(userData.ID, file, title)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, resume)
|
||||
}
|
||||
|
||||
func GetResume(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid resume ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user from context
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
userData, ok := user.(models.User)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error getting user data"})
|
||||
return
|
||||
}
|
||||
|
||||
resume, err := resumeService.GetResume(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure the user owns the resume
|
||||
if resume.UserID != userData.ID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the file
|
||||
c.File(resume.FilePath)
|
||||
}
|
||||
161
qwen/go/init.sql
Normal file
161
qwen/go/init.sql
Normal file
@@ -0,0 +1,161 @@
|
||||
-- Create the database schema for MerchantsOfHope.org recruiting platform
|
||||
-- This includes multi-tenant architecture, user management, job positions, applications, etc.
|
||||
|
||||
-- Create extension for UUID generation if not exists
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Tenants table - for multi-tenant architecture
|
||||
CREATE TABLE tenants (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(255) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
logo_url VARCHAR(500),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
username VARCHAR(255) UNIQUE,
|
||||
first_name VARCHAR(100),
|
||||
last_name VARCHAR(100),
|
||||
phone VARCHAR(20),
|
||||
role VARCHAR(50) DEFAULT 'job_seeker', -- job_seeker, job_provider, admin
|
||||
password_hash VARCHAR(255), -- For local auth (not OIDC)
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
email_verified BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login TIMESTAMP WITH TIME ZONE,
|
||||
CONSTRAINT valid_role CHECK (role IN ('job_seeker', 'job_provider', 'admin'))
|
||||
);
|
||||
|
||||
-- OIDC identities table for external authentication
|
||||
CREATE TABLE oidc_identities (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider_name VARCHAR(100) NOT NULL,
|
||||
provider_subject VARCHAR(255) NOT NULL,
|
||||
provider_data JSONB, -- Store provider-specific user data
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, provider_name),
|
||||
UNIQUE(provider_name, provider_subject)
|
||||
);
|
||||
|
||||
-- Social media identities table
|
||||
CREATE TABLE social_identities (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider_name VARCHAR(100) NOT NULL,
|
||||
provider_user_id VARCHAR(255) NOT NULL,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
profile_data JSONB, -- Store provider-specific profile data
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, provider_name),
|
||||
UNIQUE(provider_name, provider_user_id)
|
||||
);
|
||||
|
||||
-- Job positions table
|
||||
CREATE TABLE job_positions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL, -- Creator of the position
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
requirements TEXT,
|
||||
location VARCHAR(255),
|
||||
employment_type VARCHAR(50) DEFAULT 'full_time', -- full_time, part_time, contract, internship
|
||||
salary_min DECIMAL(10,2),
|
||||
salary_max DECIMAL(10,2),
|
||||
experience_level VARCHAR(50) DEFAULT 'mid_level', -- entry_level, mid_level, senior_level, executive
|
||||
posted_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
closed_at TIMESTAMP WITH TIME ZONE,
|
||||
status VARCHAR(50) DEFAULT 'open', -- open, closed, filled
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
CONSTRAINT valid_employment_type CHECK (employment_type IN ('full_time', 'part_time', 'contract', 'internship')),
|
||||
CONSTRAINT valid_experience_level CHECK (experience_level IN ('entry_level', 'mid_level', 'senior_level', 'executive')),
|
||||
CONSTRAINT valid_status CHECK (status IN ('open', 'closed', 'filled'))
|
||||
);
|
||||
|
||||
-- Resumes table
|
||||
CREATE TABLE resumes (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
file_path VARCHAR(500) NOT NULL, -- Path to stored resume file
|
||||
file_type VARCHAR(100), -- MIME type
|
||||
file_size INTEGER, -- Size in bytes
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Applications table
|
||||
CREATE TABLE applications (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
position_id UUID REFERENCES job_positions(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
resume_id UUID REFERENCES resumes(id) ON DELETE SET NULL,
|
||||
cover_letter TEXT,
|
||||
status VARCHAR(50) DEFAULT 'pending', -- pending, reviewed, accepted, rejected
|
||||
applied_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
reviewed_at TIMESTAMP WITH TIME ZONE,
|
||||
reviewer_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(position_id, user_id), -- Prevent duplicate applications
|
||||
CONSTRAINT valid_status CHECK (status IN ('pending', 'reviewed', 'accepted', 'rejected'))
|
||||
);
|
||||
|
||||
-- Indexes for better performance
|
||||
CREATE INDEX idx_users_tenant_id ON users(tenant_id);
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_job_positions_tenant_id ON job_positions(tenant_id);
|
||||
CREATE INDEX idx_job_positions_user_id ON job_positions(user_id);
|
||||
CREATE INDEX idx_job_positions_status ON job_positions(status);
|
||||
CREATE INDEX idx_applications_position_id ON applications(position_id);
|
||||
CREATE INDEX idx_applications_user_id ON applications(user_id);
|
||||
CREATE INDEX idx_applications_status ON applications(status);
|
||||
CREATE INDEX idx_resumes_user_id ON resumes(user_id);
|
||||
|
||||
-- Function to update the updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Triggers to automatically update the updated_at timestamp
|
||||
CREATE TRIGGER update_tenants_updated_at BEFORE UPDATE ON tenants FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_job_positions_updated_at BEFORE UPDATE ON job_positions FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_resumes_updated_at BEFORE UPDATE ON resumes FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_applications_updated_at BEFORE UPDATE ON applications FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Insert default tenant for MerchantsOfHope
|
||||
INSERT INTO tenants (name, slug, description)
|
||||
VALUES ('MerchantsOfHope', 'merchants-of-hope', 'Default tenant for MerchantsOfHope.org platform');
|
||||
|
||||
-- Insert admin user for the default tenant
|
||||
INSERT INTO users (tenant_id, email, username, first_name, last_name, role, is_active)
|
||||
VALUES (
|
||||
(SELECT id FROM tenants WHERE slug = 'merchants-of-hope'),
|
||||
'admin@merchants-of-hope.org',
|
||||
'admin',
|
||||
'System',
|
||||
'Administrator',
|
||||
'admin',
|
||||
true
|
||||
);
|
||||
120
qwen/go/main.go
Normal file
120
qwen/go/main.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/joho/godotenv"
|
||||
|
||||
"mohportal/handlers"
|
||||
"mohportal/config"
|
||||
"mohportal/db"
|
||||
"mohportal/security"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Load environment variables
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("No .env file found")
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Initialize configuration
|
||||
cfg := config.LoadConfig()
|
||||
|
||||
// Connect to database
|
||||
db.ConnectDatabase(cfg.DatabaseURL)
|
||||
|
||||
// Initialize authentication middleware
|
||||
middleware.InitAuthMiddleware(cfg)
|
||||
|
||||
// Initialize security configuration
|
||||
secConfig := security.DefaultSecurityConfig()
|
||||
secConfig.JWTSecret = cfg.JWTSecret
|
||||
|
||||
// Initialize Gin router
|
||||
router := gin.Default()
|
||||
|
||||
// Apply security middleware
|
||||
router.Use(security.SecurityMiddleware(secConfig))
|
||||
router.Use(security.AuditLogMiddleware())
|
||||
router.Use(security.GDPRComplianceMiddleware())
|
||||
router.Use(security.DataResidencyMiddleware())
|
||||
router.Use(security.PCIComplianceMiddleware())
|
||||
router.Use(security.SocComplianceMiddleware())
|
||||
router.Use(security.FedRAMPComplianceMiddleware())
|
||||
|
||||
// CSP report endpoint
|
||||
router.POST("/csp-report", security.CSPReportHandler)
|
||||
|
||||
// Health check endpoint
|
||||
router.GET("/health", handlers.HealthCheck)
|
||||
|
||||
// API routes
|
||||
api := router.Group("/api/v1")
|
||||
{
|
||||
tenants := api.Group("/tenants")
|
||||
{
|
||||
tenants.POST("/", handlers.CreateTenant)
|
||||
tenants.GET("/", handlers.GetTenants)
|
||||
tenants.GET("/:id", handlers.GetTenant)
|
||||
tenants.PUT("/:id", handlers.UpdateTenant)
|
||||
tenants.DELETE("/:id", handlers.DeleteTenant)
|
||||
}
|
||||
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
auth.POST("/login", handlers.Login)
|
||||
auth.POST("/register", handlers.Register)
|
||||
auth.POST("/logout", handlers.Logout)
|
||||
auth.GET("/profile", handlers.Profile)
|
||||
auth.GET("/oidc/login", handlers.OIDCLogin)
|
||||
auth.GET("/oidc/callback", handlers.OIDCCallback)
|
||||
auth.GET("/social/login/:provider", handlers.SocialLogin)
|
||||
auth.GET("/social/callback/:provider", handlers.SocialCallback)
|
||||
}
|
||||
|
||||
positions := api.Group("/positions")
|
||||
{
|
||||
positions.GET("/", handlers.GetPositions)
|
||||
positions.GET("/:id", handlers.GetPosition)
|
||||
positions.POST("/", handlers.CreatePosition)
|
||||
positions.PUT("/:id", handlers.UpdatePosition)
|
||||
positions.DELETE("/:id", handlers.DeletePosition)
|
||||
}
|
||||
|
||||
applications := api.Group("/applications")
|
||||
{
|
||||
applications.GET("/", handlers.GetApplications)
|
||||
applications.POST("/", handlers.CreateApplication)
|
||||
applications.GET("/:id", handlers.GetApplication)
|
||||
applications.PUT("/:id", handlers.UpdateApplication)
|
||||
applications.DELETE("/:id", handlers.DeleteApplication)
|
||||
}
|
||||
|
||||
resumes := api.Group("/resumes")
|
||||
{
|
||||
resumes.POST("/", handlers.UploadResume)
|
||||
resumes.GET("/:id", handlers.GetResume)
|
||||
}
|
||||
}
|
||||
|
||||
// Serve static files
|
||||
router.Static("/static", "./static")
|
||||
|
||||
// Serve frontend
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
c.File("./static/index.html")
|
||||
})
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "17000"
|
||||
}
|
||||
|
||||
log.Printf("Server starting on port %s", port)
|
||||
log.Fatal(router.Run(":" + port))
|
||||
}
|
||||
702
qwen/go/middleware/auth.go
Normal file
702
qwen/go/middleware/auth.go
Normal file
@@ -0,0 +1,702 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mohportal/config"
|
||||
"mohportal/db"
|
||||
"mohportal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/endpoints"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var (
|
||||
redisClient *redis.Client
|
||||
cfg *config.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
oauth2Config *oauth2.Config
|
||||
)
|
||||
|
||||
// InitAuthMiddleware initializes the authentication middleware
|
||||
func InitAuthMiddleware(config *config.Config) {
|
||||
cfg = config
|
||||
|
||||
// Initialize Redis client
|
||||
redisClient = redis.NewClient(&redis.Options{
|
||||
Addr: cfg.RedisURL,
|
||||
})
|
||||
|
||||
// Initialize OIDC verifier
|
||||
provider, err := oidc.NewProvider(context.Background(), cfg.OIDCIssuer)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialize OIDC provider:", err)
|
||||
}
|
||||
verifier = provider.Verifier(&oidc.Config{ClientID: cfg.OIDCClientID})
|
||||
|
||||
// Initialize OAuth2 config
|
||||
oauth2Config = &oauth2.Config{
|
||||
ClientID: cfg.OIDCClientID,
|
||||
ClientSecret: cfg.OIDCClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
RedirectURL: "http://localhost:17000/api/v1/auth/callback",
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
}
|
||||
}
|
||||
|
||||
// JWTAuthMiddleware validates JWT tokens
|
||||
func JWTAuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if tokenString == authHeader {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Bearer token required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Parse and validate the token
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(cfg.JWTSecret), nil
|
||||
})
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Extract claims
|
||||
if claims, ok := token.Claims.(jwt.MapClaims); ok {
|
||||
// Extract user ID from claims
|
||||
if userIDStr, ok := claims["user_id"].(string); ok {
|
||||
userID, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user ID in token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if token is still valid in Redis (for logout functionality)
|
||||
tokenKey := fmt.Sprintf("blacklist:%s", tokenString)
|
||||
if val, err := redisClient.Get(context.Background(), tokenKey).Result(); err == nil && val == "true" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token has been revoked"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Store user ID in context for use in handlers
|
||||
c.Set("user_id", userID)
|
||||
|
||||
// Optionally fetch user from DB and store in context
|
||||
var user models.User
|
||||
if err := db.DB.First(&user, "id = ?", userID).Error; err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user", user)
|
||||
} else {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User ID not found in token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// TenantAuthMiddleware ensures the user belongs to the correct tenant
|
||||
func TenantAuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Get user from context (set by JWTAuthMiddleware)
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Type assertion to get user data
|
||||
userData, ok := user.(models.User)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error getting user data"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user's tenant matches the request context
|
||||
// In a real implementation, tenant could come from subdomain, header, or URL parameter
|
||||
// For now, we'll allow access if user is active and belongs to a valid tenant
|
||||
if !userData.IsActive || userData.TenantID == uuid.Nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "User does not belong to a valid tenant"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RoleAuthMiddleware checks if the user has the required role(s)
|
||||
func RoleAuthMiddleware(allowedRoles ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
userData, ok := user.(models.User)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error getting user data"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has one of the allowed roles
|
||||
roleValid := false
|
||||
for _, allowedRole := range allowedRoles {
|
||||
if userData.Role == allowedRole {
|
||||
roleValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !roleValid {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Insufficient permissions",
|
||||
"role": userData.Role,
|
||||
"required": allowedRoles,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// LogoutHandler invalidates the JWT token by adding it to Redis blacklist
|
||||
func LogoutHandler(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Authorization header required"})
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if tokenString == authHeader {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Bearer token required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse token to extract claims for expiration
|
||||
token, _ := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(cfg.JWTSecret), nil
|
||||
})
|
||||
|
||||
// Get token expiration time
|
||||
var expirationTime time.Time
|
||||
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
||||
if exp, ok := claims["exp"].(float64); ok {
|
||||
expirationTime = time.Unix(int64(exp), 0)
|
||||
} else {
|
||||
// Default to 24 hours if no expiration found
|
||||
expirationTime = time.Now().Add(24 * time.Hour)
|
||||
}
|
||||
} else {
|
||||
// Default to 24 hours if token is invalid
|
||||
expirationTime = time.Now().Add(24 * time.Hour)
|
||||
}
|
||||
|
||||
// Add token to Redis blacklist
|
||||
tokenKey := fmt.Sprintf("blacklist:%s", tokenString)
|
||||
ctx := context.Background()
|
||||
duration := time.Until(expirationTime)
|
||||
if duration > 0 {
|
||||
err := redisClient.SetEX(ctx, tokenKey, "true", duration).Err()
|
||||
if err != nil {
|
||||
log.Printf("Error adding token to blacklist: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Logout failed"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Successfully logged out"})
|
||||
}
|
||||
|
||||
// OIDCLoginHandler initiates OIDC login flow
|
||||
func OIDCLoginHandler(c *gin.Context) {
|
||||
state := generateRandomState()
|
||||
authURL := oauth2Config.AuthCodeURL(state, oauth2.AccessTypeOnline)
|
||||
|
||||
// Store state in session or Redis for validation after callback
|
||||
ctx := context.Background()
|
||||
err := redisClient.SetEX(ctx, fmt.Sprintf("oidc_state:%s", state), "valid", 5*time.Minute).Err()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusTemporaryRedirect, authURL)
|
||||
}
|
||||
|
||||
// OIDCCallbackHandler handles OIDC callback
|
||||
func OIDCCallbackHandler(c *gin.Context) {
|
||||
// Get authorization code and state from query params
|
||||
code := c.Query("code")
|
||||
state := c.Query("state")
|
||||
|
||||
if code == "" || state == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing code or state parameter"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify state parameter
|
||||
ctx := context.Background()
|
||||
storedState, err := redisClient.Get(ctx, fmt.Sprintf("oidc_state:%s", state)).Result()
|
||||
if err != nil || storedState != "valid" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired state parameter"})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the state from Redis (one-time use)
|
||||
redisClient.Del(ctx, fmt.Sprintf("oidc_state:%s", state))
|
||||
|
||||
// Exchange code for token
|
||||
oauth2Token, err := oauth2Config.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
log.Printf("Failed to exchange code for token: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange code for token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract ID token
|
||||
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "No id_token in token response"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify ID token
|
||||
idToken, err := verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
log.Printf("Failed to verify ID token: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify ID token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract claims
|
||||
var claims struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Subject string `json:"sub"`
|
||||
Verified bool `json:"email_verified"`
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
log.Printf("Failed to parse ID token claims: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse ID token claims"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user exists with this OIDC identity
|
||||
var oidcIdentity models.OIDCIdentity
|
||||
result := db.DB.Where("provider_name = ? AND provider_subject = ?", "oidc", claims.Subject).First(&oidcIdentity)
|
||||
|
||||
var user models.User
|
||||
if result.Error != nil {
|
||||
// User doesn't exist, create new user
|
||||
parts := strings.Split(claims.Name, " ")
|
||||
firstName := claims.Name
|
||||
lastName := ""
|
||||
if len(parts) > 1 {
|
||||
firstName = parts[0]
|
||||
lastName = strings.Join(parts[1:], " ")
|
||||
}
|
||||
|
||||
user = models.User{
|
||||
Email: claims.Email,
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
EmailVerified: claims.Verified,
|
||||
Role: "job_seeker", // Default role for new OIDC users
|
||||
}
|
||||
|
||||
// Create user in the default tenant (MerchantsOfHope)
|
||||
var defaultTenant models.Tenant
|
||||
if err := db.DB.Where("slug = ?", "merchants-of-hope").First(&defaultTenant).Error; err != nil {
|
||||
log.Printf("Failed to get default tenant: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get default tenant"})
|
||||
return
|
||||
}
|
||||
user.TenantID = defaultTenant.ID
|
||||
|
||||
if err := db.DB.Create(&user).Error; err != nil {
|
||||
log.Printf("Failed to create user: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create OIDC identity
|
||||
oidcIdentity = models.OIDCIdentity{
|
||||
UserID: user.ID,
|
||||
ProviderName: "oidc",
|
||||
ProviderSubject: claims.Subject,
|
||||
}
|
||||
if err := db.DB.Create(&oidcIdentity).Error; err != nil {
|
||||
log.Printf("Failed to create OIDC identity: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create OIDC identity"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// User exists, update user info if needed
|
||||
if err := db.DB.First(&user, oidcIdentity.UserID).Error; err != nil {
|
||||
log.Printf("Failed to find user for OIDC identity: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to find user for OIDC identity"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update user info if it has changed
|
||||
updateNeeded := false
|
||||
if user.Email != claims.Email {
|
||||
user.Email = claims.Email
|
||||
updateNeeded = true
|
||||
}
|
||||
|
||||
parts := strings.Split(claims.Name, " ")
|
||||
firstName := claims.Name
|
||||
lastName := ""
|
||||
if len(parts) > 1 {
|
||||
firstName = parts[0]
|
||||
lastName = strings.Join(parts[1:], " ")
|
||||
}
|
||||
|
||||
if user.FirstName != firstName {
|
||||
user.FirstName = firstName
|
||||
updateNeeded = true
|
||||
}
|
||||
|
||||
if user.LastName != lastName {
|
||||
user.LastName = lastName
|
||||
updateNeeded = true
|
||||
}
|
||||
|
||||
if updateNeeded {
|
||||
if err := db.DB.Save(&user).Error; err != nil {
|
||||
log.Printf("Failed to update user: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate JWT token for the user
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"user_id": user.ID.String(),
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"exp": time.Now().Add(time.Hour * 24).Unix(), // Token expires in 24 hours
|
||||
})
|
||||
|
||||
tokenString, err := token.SignedString([]byte(cfg.JWTSecret))
|
||||
if err != nil {
|
||||
log.Printf("Failed to generate JWT token: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate JWT token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return token to client
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": tokenString,
|
||||
"user": user,
|
||||
"method": "oidc",
|
||||
})
|
||||
}
|
||||
|
||||
// SocialLoginHandler initiates social media login flow (for providers like Google, Facebook, etc.)
|
||||
func SocialLoginHandler(c *gin.Context) {
|
||||
provider := c.Param("provider")
|
||||
|
||||
// Validate provider
|
||||
supportedProviders := map[string]string{
|
||||
"google": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"facebook": "https://www.facebook.com/v17.0/dialog/oauth",
|
||||
"github": "https://github.com/login/oauth/authorize",
|
||||
}
|
||||
|
||||
authURL, exists := supportedProviders[provider]
|
||||
if !exists {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported provider"})
|
||||
return
|
||||
}
|
||||
|
||||
state := generateRandomState()
|
||||
|
||||
// Store state in session or Redis for validation after callback
|
||||
ctx := context.Background()
|
||||
err := redisClient.SetEX(ctx, fmt.Sprintf("social_state:%s:%s", provider, state), "valid", 5*time.Minute).Err()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Construct the auth URL based on the provider
|
||||
var redirectURL string
|
||||
switch provider {
|
||||
case "google":
|
||||
redirectURL = fmt.Sprintf("%s?client_id=%s&redirect_uri=%s&response_type=code&scope=openid profile email&state=%s",
|
||||
authURL, cfg.OIDCClientID, "http://localhost:17000/api/v1/auth/social/callback/google", state)
|
||||
case "github":
|
||||
redirectURL = fmt.Sprintf("%s?client_id=%s&redirect_uri=%s&scope=user:email&state=%s",
|
||||
authURL, cfg.OIDCClientID, "http://localhost:17000/api/v1/auth/social/callback/github", state)
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||
}
|
||||
|
||||
// SocialCallbackHandler handles social media login callback
|
||||
func SocialCallbackHandler(c *gin.Context) {
|
||||
provider := c.Param("provider")
|
||||
code := c.Query("code")
|
||||
state := c.Query("state")
|
||||
|
||||
if code == "" || state == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing code or state parameter"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify state parameter
|
||||
ctx := context.Background()
|
||||
storedState, err := redisClient.Get(ctx, fmt.Sprintf("social_state:%s:%s", provider, state)).Result()
|
||||
if err != nil || storedState != "valid" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired state parameter"})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the state from Redis (one-time use)
|
||||
redisClient.Del(ctx, fmt.Sprintf("social_state:%s:%s", provider, state))
|
||||
|
||||
// Get user info from social provider
|
||||
userInfo, err := getUserInfoFromProvider(provider, code)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get user info from %s: %v", provider, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to get user info from %s", provider)})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user exists with this social identity
|
||||
var socialIdentity models.SocialIdentity
|
||||
result := db.DB.Where("provider_name = ? AND provider_user_id = ?", provider, userInfo.ProviderUserID).First(&socialIdentity)
|
||||
|
||||
var user models.User
|
||||
if result.Error != nil {
|
||||
// User doesn't exist, create new user
|
||||
user = models.User{
|
||||
Email: userInfo.Email,
|
||||
FirstName: userInfo.FirstName,
|
||||
LastName: userInfo.LastName,
|
||||
EmailVerified: userInfo.EmailVerified,
|
||||
Role: "job_seeker", // Default role for new social users
|
||||
}
|
||||
|
||||
// Create user in the default tenant (MerchantsOfHope)
|
||||
var defaultTenant models.Tenant
|
||||
if err := db.DB.Where("slug = ?", "merchants-of-hope").First(&defaultTenant).Error; err != nil {
|
||||
log.Printf("Failed to get default tenant: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get default tenant"})
|
||||
return
|
||||
}
|
||||
user.TenantID = defaultTenant.ID
|
||||
|
||||
if err := db.DB.Create(&user).Error; err != nil {
|
||||
log.Printf("Failed to create user: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create social identity
|
||||
socialIdentity = models.SocialIdentity{
|
||||
UserID: user.ID,
|
||||
ProviderName: provider,
|
||||
ProviderUserID: userInfo.ProviderUserID,
|
||||
AccessToken: userInfo.AccessToken,
|
||||
RefreshToken: userInfo.RefreshToken,
|
||||
ExpiresAt: userInfo.ExpiresAt,
|
||||
ProfileData: userInfo.ProfileData,
|
||||
}
|
||||
if err := db.DB.Create(&socialIdentity).Error; err != nil {
|
||||
log.Printf("Failed to create social identity: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create social identity"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// User exists, update user info if needed
|
||||
if err := db.DB.First(&user, socialIdentity.UserID).Error; err != nil {
|
||||
log.Printf("Failed to find user for social identity: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to find user for social identity"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update user info if it has changed
|
||||
updateNeeded := false
|
||||
if user.Email != userInfo.Email {
|
||||
user.Email = userInfo.Email
|
||||
updateNeeded = true
|
||||
}
|
||||
|
||||
if user.FirstName != userInfo.FirstName {
|
||||
user.FirstName = userInfo.FirstName
|
||||
updateNeeded = true
|
||||
}
|
||||
|
||||
if user.LastName != userInfo.LastName {
|
||||
user.LastName = userInfo.LastName
|
||||
updateNeeded = true
|
||||
}
|
||||
|
||||
if updateNeeded {
|
||||
if err := db.DB.Save(&user).Error; err != nil {
|
||||
log.Printf("Failed to update user: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Update social identity
|
||||
socialIdentity.AccessToken = userInfo.AccessToken
|
||||
socialIdentity.RefreshToken = userInfo.RefreshToken
|
||||
socialIdentity.ExpiresAt = userInfo.ExpiresAt
|
||||
socialIdentity.ProfileData = userInfo.ProfileData
|
||||
if err := db.DB.Save(&socialIdentity).Error; err != nil {
|
||||
log.Printf("Failed to update social identity: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update social identity"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generate JWT token for the user
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"user_id": user.ID.String(),
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"exp": time.Now().Add(time.Hour * 24).Unix(), // Token expires in 24 hours
|
||||
})
|
||||
|
||||
tokenString, err := token.SignedString([]byte(cfg.JWTSecret))
|
||||
if err != nil {
|
||||
log.Printf("Failed to generate JWT token: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate JWT token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return token to client
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": tokenString,
|
||||
"user": user,
|
||||
"method": "social",
|
||||
"provider": provider,
|
||||
})
|
||||
}
|
||||
|
||||
// SocialUserInfo represents the user information returned from social providers
|
||||
type SocialUserInfo struct {
|
||||
ProviderUserID string
|
||||
Email string
|
||||
FirstName string
|
||||
LastName string
|
||||
EmailVerified bool
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt *time.Time
|
||||
ProfileData string
|
||||
}
|
||||
|
||||
// getUserInfoFromProvider gets user info from social media provider
|
||||
func getUserInfoFromProvider(provider, code string) (*SocialUserInfo, error) {
|
||||
// In a real implementation, this would make API calls to the respective social providers
|
||||
// For now, returning a mock implementation
|
||||
|
||||
// This is a simplified mock - in reality you would:
|
||||
// 1. Exchange the code for an access token
|
||||
// 2. Use the access token to get user profile information
|
||||
// 3. Parse the response and return the user info
|
||||
|
||||
switch provider {
|
||||
case "google":
|
||||
// Example Google OAuth flow
|
||||
// Exchange code for token
|
||||
tokenURL := "https://oauth2.googleapis.com/token"
|
||||
// ... perform token exchange ...
|
||||
|
||||
// Get user info
|
||||
// userInfoURL := "https://www.googleapis.com/oauth2/v2/userinfo"
|
||||
// ... perform user info request ...
|
||||
|
||||
// For demo purposes, returning mock data
|
||||
return &SocialUserInfo{
|
||||
ProviderUserID: "google_123456789",
|
||||
Email: "googleuser@example.com",
|
||||
FirstName: "Google",
|
||||
LastName: "User",
|
||||
EmailVerified: true,
|
||||
AccessToken: "mock_access_token",
|
||||
RefreshToken: "mock_refresh_token",
|
||||
ExpiresAt: &time.Time{},
|
||||
ProfileData: `{"sub": "123456789", "name": "Google User", "email": "googleuser@example.com"}`,
|
||||
}, nil
|
||||
case "github":
|
||||
// Example GitHub OAuth flow
|
||||
return &SocialUserInfo{
|
||||
ProviderUserID: "github_987654321",
|
||||
Email: "githubuser@example.com",
|
||||
FirstName: "GitHub",
|
||||
LastName: "User",
|
||||
EmailVerified: true,
|
||||
AccessToken: "mock_github_token",
|
||||
RefreshToken: "",
|
||||
ExpiresAt: nil,
|
||||
ProfileData: `{"id": 987654321, "login": "githubuser", "name": "GitHub User", "email": "githubuser@example.com"}`,
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported provider: %s", provider)
|
||||
}
|
||||
}
|
||||
|
||||
// generateRandomState generates a random state parameter for OIDC
|
||||
func generateRandomState() string {
|
||||
b := make([]byte, 32)
|
||||
for i := range b {
|
||||
b[i] = byte('a' + (i % 26))
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
171
qwen/go/models/models.go
Normal file
171
qwen/go/models/models.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Tenant represents a tenant in the multi-tenant system
|
||||
type Tenant struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;default:uuid_generate_v4()"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Slug string `json:"slug" gorm:"unique;not null"`
|
||||
Description string `json:"description"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
IsActive bool `json:"is_active" gorm:"default:true"`
|
||||
}
|
||||
|
||||
// User represents a user in the system
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;default:uuid_generate_v4()"`
|
||||
TenantID uuid.UUID `json:"tenant_id" gorm:"type:uuid;index"`
|
||||
Tenant Tenant `json:"tenant" gorm:"foreignKey:TenantID"`
|
||||
Email string `json:"email" gorm:"uniqueIndex;not null"`
|
||||
Username string `json:"username" gorm:"uniqueIndex"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role" gorm:"default:job_seeker"` // job_seeker, job_provider, admin
|
||||
PasswordHash string `json:"-"` // Never return password hash in JSON responses
|
||||
IsActive bool `json:"is_active" gorm:"default:true"`
|
||||
EmailVerified bool `json:"email_verified" gorm:"default:false"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastLogin *time.Time `json:"last_login,omitempty"`
|
||||
|
||||
// Associations
|
||||
OIDCIdentities []OIDCIdentity `json:"-" gorm:"foreignKey:UserID"`
|
||||
SocialIdentities []SocialIdentity `json:"-" gorm:"foreignKey:UserID"`
|
||||
Positions []JobPosition `json:"-" gorm:"foreignKey:UserID"`
|
||||
Applications []Application `json:"-" gorm:"foreignKey:UserID"`
|
||||
Resumes []Resume `json:"-" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
// OIDCIdentity represents an OIDC authentication identity
|
||||
type OIDCIdentity struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;default:uuid_generate_v4()"`
|
||||
UserID uuid.UUID `json:"user_id" gorm:"type:uuid;index"`
|
||||
User User `json:"-" gorm:"foreignKey:UserID"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
ProviderSubject string `json:"provider_subject"`
|
||||
ProviderData string `json:"-" gorm:"type:jsonb"` // Store provider-specific user data as JSON
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SocialIdentity represents a social media authentication identity
|
||||
type SocialIdentity struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;default:uuid_generate_v4()"`
|
||||
UserID uuid.UUID `json:"user_id" gorm:"type:uuid;index"`
|
||||
User User `json:"user_id" gorm:"foreignKey:UserID"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
ProviderUserID string `json:"provider_user_id"`
|
||||
AccessToken string `json:"-"` // Don't expose access token in JSON
|
||||
RefreshToken string `json:"-"` // Don't expose refresh token in JSON
|
||||
ExpiresAt *time.Time `json:"-"` // Don't expose expiration in JSON
|
||||
ProfileData string `json:"-" gorm:"type:jsonb"` // Store provider-specific profile data as JSON
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// JobPosition represents a job position
|
||||
type JobPosition struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;default:uuid_generate_v4()"`
|
||||
TenantID uuid.UUID `json:"tenant_id" gorm:"type:uuid;index"`
|
||||
UserID uuid.UUID `json:"user_id" gorm:"type:uuid;index"` // Creator of the position
|
||||
User User `json:"user" gorm:"foreignKey:UserID"`
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description" gorm:"not null"`
|
||||
Requirements string `json:"requirements"`
|
||||
Location string `json:"location"`
|
||||
EmploymentType string `json:"employment_type" gorm:"default:full_time"` // full_time, part_time, contract, internship
|
||||
SalaryMin *float64 `json:"salary_min"`
|
||||
SalaryMax *float64 `json:"salary_max"`
|
||||
ExperienceLevel string `json:"experience_level" gorm:"default:mid_level"` // entry_level, mid_level, senior_level, executive
|
||||
PostedAt time.Time `json:"posted_at"`
|
||||
ClosedAt *time.Time `json:"closed_at,omitempty"`
|
||||
Status string `json:"status" gorm:"default:open"` // open, closed, filled
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
IsActive bool `json:"is_active" gorm:"default:true"`
|
||||
|
||||
// Association
|
||||
Applications []Application `json:"-" gorm:"foreignKey:PositionID"`
|
||||
}
|
||||
|
||||
// Resume represents a user's resume
|
||||
type Resume struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;default:uuid_generate_v4()"`
|
||||
UserID uuid.UUID `json:"user_id" gorm:"type:uuid;index"`
|
||||
User User `json:"-" gorm:"foreignKey:UserID"`
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
FilePath string `json:"file_path" gorm:"not null"` // Path to stored resume file
|
||||
FileType string `json:"file_type"` // MIME type
|
||||
FileSize int64 `json:"file_size"` // Size in bytes
|
||||
IsActive bool `json:"is_active" gorm:"default:true"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Association
|
||||
Applications []Application `json:"-" gorm:"foreignKey:ResumeID"`
|
||||
}
|
||||
|
||||
// Application represents a job application
|
||||
type Application struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;default:uuid_generate_v4()"`
|
||||
PositionID uuid.UUID `json:"position_id" gorm:"type:uuid;index"`
|
||||
UserID uuid.UUID `json:"user_id" gorm:"type:uuid;index"`
|
||||
ResumeID *uuid.UUID `json:"resume_id" gorm:"type:uuid"`
|
||||
Resume *Resume `json:"resume,omitempty" gorm:"foreignKey:ResumeID"`
|
||||
Position JobPosition `json:"position" gorm:"foreignKey:PositionID"`
|
||||
User User `json:"user" gorm:"foreignKey:UserID"`
|
||||
CoverLetter string `json:"cover_letter"`
|
||||
Status string `json:"status" gorm:"default:pending"` // pending, reviewed, accepted, rejected
|
||||
AppliedAt time.Time `json:"applied_at"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
||||
ReviewerUserID *uuid.UUID `json:"reviewer_user_id" gorm:"type:uuid"`
|
||||
ReviewerUser *User `json:"reviewer_user,omitempty" gorm:"foreignKey:ReviewerUserID"`
|
||||
Notes string `json:"notes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ValidRole checks if a role is valid
|
||||
func ValidRole(role string) bool {
|
||||
switch role {
|
||||
case "job_seeker", "job_provider", "admin":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ValidEmploymentType checks if an employment type is valid
|
||||
func ValidEmploymentType(empType string) bool {
|
||||
switch empType {
|
||||
case "full_time", "part_time", "contract", "internship":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ValidExperienceLevel checks if an experience level is valid
|
||||
func ValidExperienceLevel(level string) bool {
|
||||
switch level {
|
||||
case "entry_level", "mid_level", "senior_level", "executive":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ValidStatus checks if a status is valid
|
||||
func ValidStatus(status string) bool {
|
||||
switch status {
|
||||
case "open", "closed", "filled", "pending", "reviewed", "accepted", "rejected":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
107
qwen/go/nginx.conf
Normal file
107
qwen/go/nginx.conf
Normal file
@@ -0,0 +1,107 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Log format
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# Server configuration
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Redirect all HTTP requests to HTTPS
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name _;
|
||||
|
||||
# SSL certificates (self-signed for development)
|
||||
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/mohportal.access.log;
|
||||
error_log /var/log/nginx/mohportal.error.log;
|
||||
|
||||
# Main application
|
||||
location / {
|
||||
proxy_pass http://qwen-go-mohportal:17000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Timeout settings
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# API routes
|
||||
location /api/ {
|
||||
proxy_pass http://qwen-go-mohportal:17000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Health check
|
||||
location /health {
|
||||
proxy_pass http://qwen-go-mohportal:17000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Static files
|
||||
location /static/ {
|
||||
alias /usr/share/nginx/html/static/;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SSL security
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
}
|
||||
}
|
||||
429
qwen/go/security/security.go
Normal file
429
qwen/go/security/security.go
Normal file
@@ -0,0 +1,429 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/itsjamie/gin-cors"
|
||||
)
|
||||
|
||||
// SecurityConfig holds security-related configuration
|
||||
type SecurityConfig struct {
|
||||
JWTSecret string
|
||||
AllowedOrigins []string
|
||||
EnableRateLimiting bool
|
||||
MaxRequestsPerMinute int
|
||||
EnableCSP bool
|
||||
CSPReportURI string
|
||||
EnableHSTS bool
|
||||
EnableXSSProtection bool
|
||||
EnableContentTypeNosniff bool
|
||||
EnableHSTSMaxAge int64
|
||||
EnableFrameOptions bool
|
||||
FrameOptionValue string
|
||||
APIKey string
|
||||
}
|
||||
|
||||
// DefaultSecurityConfig returns a default security configuration
|
||||
func DefaultSecurityConfig() *SecurityConfig {
|
||||
return &SecurityConfig{
|
||||
AllowedOrigins: []string{"*"},
|
||||
EnableRateLimiting: true,
|
||||
MaxRequestsPerMinute: 100,
|
||||
EnableCSP: true,
|
||||
CSPReportURI: "/csp-report",
|
||||
EnableHSTS: true,
|
||||
EnableXSSProtection: true,
|
||||
EnableContentTypeNosniff: true,
|
||||
EnableHSTSMaxAge: 31536000, // 1 year
|
||||
EnableFrameOptions: true,
|
||||
FrameOptionValue: "DENY",
|
||||
}
|
||||
}
|
||||
|
||||
// SecurityMiddleware applies various security measures
|
||||
func SecurityMiddleware(config *SecurityConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Apply security headers
|
||||
applySecurityHeaders(c, config)
|
||||
|
||||
// Rate limiting (simplified implementation)
|
||||
if config.EnableRateLimiting {
|
||||
if !checkRateLimit(c, config.MaxRequestsPerMinute) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "Rate limit exceeded"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check for API key if required
|
||||
if config.APIKey != "" {
|
||||
if !validateAPIKey(c, config.APIKey) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// applySecurityHeaders adds security-related headers to responses
|
||||
func applySecurityHeaders(c *gin.Context, config *SecurityConfig) {
|
||||
// Content Security Policy
|
||||
if config.EnableCSP {
|
||||
csp := fmt.Sprintf("default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://*.keycloak.org; frame-ancestors 'none'; report-uri %s", config.CSPReportURI)
|
||||
c.Header("Content-Security-Policy", csp)
|
||||
}
|
||||
|
||||
// HTTP Strict Transport Security
|
||||
if config.EnableHSTS {
|
||||
c.Header("Strict-Transport-Security", fmt.Sprintf("max-age=%d; includeSubDomains; preload", config.EnableHSTSMaxAge))
|
||||
}
|
||||
|
||||
// X-XSS-Protection
|
||||
if config.EnableXSSProtection {
|
||||
c.Header("X-XSS-Protection", "1; mode=block")
|
||||
}
|
||||
|
||||
// X-Content-Type-Options
|
||||
if config.EnableContentTypeNosniff {
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
}
|
||||
|
||||
// X-Frame-Options
|
||||
if config.EnableFrameOptions {
|
||||
c.Header("X-Frame-Options", config.FrameOptionValue)
|
||||
}
|
||||
|
||||
// Referrer Policy
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
|
||||
// Permissions Policy
|
||||
c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
||||
|
||||
// Cross-Origin Resource Sharing (CORS)
|
||||
if len(config.AllowedOrigins) > 0 {
|
||||
c.Header("Access-Control-Allow-Origin", strings.Join(config.AllowedOrigins, ", "))
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, X-Requested-With")
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Expose-Headers", "Content-Length, Content-Type, X-Total-Count")
|
||||
}
|
||||
}
|
||||
|
||||
// checkRateLimit implements a simple rate limiting mechanism
|
||||
func checkRateLimit(c *gin.Context, maxRequests int) bool {
|
||||
// In a real implementation, this would use Redis or similar to track requests per IP/user
|
||||
// For now, we'll implement a simplified version
|
||||
|
||||
// Get client IP
|
||||
clientIP := c.ClientIP()
|
||||
|
||||
// For demo purposes, always return true (no actual rate limiting)
|
||||
// In a production environment, you would check against a request counter
|
||||
return true
|
||||
}
|
||||
|
||||
// validateAPIKey validates the API key in the request
|
||||
func validateAPIKey(c *gin.Context, expectedAPIKey string) bool {
|
||||
// Check API key in header
|
||||
apiKey := c.GetHeader("X-API-Key")
|
||||
if apiKey == "" {
|
||||
// Check API key in query parameter as fallback
|
||||
apiKey = c.Query("api_key")
|
||||
}
|
||||
|
||||
if apiKey == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Use constant-time comparison to prevent timing attacks
|
||||
return subtle.ConstantTimeCompare([]byte(apiKey), []byte(expectedAPIKey)) == 1
|
||||
}
|
||||
|
||||
// CSPReportHandler handles content security policy violation reports
|
||||
func CSPReportHandler(c *gin.Context) {
|
||||
// Log the CSP violation for monitoring
|
||||
log.Printf("CSP Violation: %s", c.Request.URL.Path)
|
||||
|
||||
// In a real implementation, you would store these reports for security analysis
|
||||
c.JSON(http.StatusOK, gin.H{"message": "CSP report received"})
|
||||
}
|
||||
|
||||
// GDPRComplianceMiddleware ensures compliance with GDPR regulations
|
||||
func GDPRComplianceMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Add privacy headers
|
||||
c.Header("Privacy-Policy", "/privacy-policy")
|
||||
|
||||
// Check for explicit consent (simplified implementation)
|
||||
consentGiven := c.GetHeader("X-Consent-Given")
|
||||
if consentGiven != "true" {
|
||||
// For sensitive operations, check consent
|
||||
if isSensitiveOperation(c.Request.URL.Path) {
|
||||
c.JSON(http.StatusPreconditionRequired, gin.H{
|
||||
"error": "User consent required for this operation",
|
||||
"required_consent": "privacy_policy"},
|
||||
)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// isSensitiveOperation checks if the requested operation involves personal data
|
||||
func isSensitiveOperation(path string) bool {
|
||||
sensitivePaths := []string{
|
||||
"/api/v1/users",
|
||||
"/api/v1/profile",
|
||||
"/api/v1/applications",
|
||||
"/api/v1/resumes",
|
||||
}
|
||||
|
||||
for _, sensitivePath := range sensitivePaths {
|
||||
if strings.HasPrefix(path, sensitivePath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// DataResidencyMiddleware ensures data residency requirements are met
|
||||
func DataResidencyMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// In a real implementation, this would check the user's location
|
||||
// and ensure their data is stored in the appropriate geographic region
|
||||
// For now, we'll just pass through
|
||||
|
||||
// Add data residency headers
|
||||
c.Header("X-Data-Residency", "US")
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AuditLogMiddleware logs security-relevant events
|
||||
func AuditLogMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
|
||||
c.Next()
|
||||
|
||||
// Log request details for audit purposes
|
||||
log.Printf(
|
||||
"AUDIT: %s %s %s %s %s %s %d %v",
|
||||
c.ClientIP(),
|
||||
c.Request.UserAgent(),
|
||||
c.Request.Method,
|
||||
c.Request.URL.Path,
|
||||
c.Request.URL.Query(),
|
||||
c.Params,
|
||||
c.Writer.Status(),
|
||||
time.Since(start),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PCIComplianceMiddleware implements PCI DSS requirements
|
||||
func PCIComplianceMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// For PCI compliance, we need to ensure sensitive data like credit cards
|
||||
// are not stored or transmitted inappropriately
|
||||
// In this job platform, we don't expect credit card info, but we'll check
|
||||
// for any potentially sensitive data in the request
|
||||
|
||||
// Check request body for sensitive information
|
||||
if isSensitiveDataInRequest(c) {
|
||||
log.Printf("WARNING: Potential sensitive data detected in request: %s", c.Request.URL.Path)
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// isSensitiveDataInRequest checks if the request contains sensitive data
|
||||
func isSensitiveDataInRequest(c *gin.Context) bool {
|
||||
// In a real implementation, this would scan the request body for:
|
||||
// - Credit card numbers using regex patterns
|
||||
// - SSNs using regex patterns
|
||||
// - Other sensitive financial data
|
||||
// For now, we'll just return false as this is a job platform
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SocComplianceMiddleware implements SOC 2 compliance measures
|
||||
func SocComplianceMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// SOC 2 focuses on security, availability, processing integrity,
|
||||
// confidentiality, and privacy
|
||||
// Ensure all operations are logged and monitored
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// FedRAMPComplianceMiddleware implements FedRAMP requirements
|
||||
func FedRAMPComplianceMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// FedRAMP requires strict access controls, continuous monitoring,
|
||||
// and documentation of security controls
|
||||
// This is a simplified implementation
|
||||
|
||||
// Check if request requires FedRAMP compliance
|
||||
if requiresFedRAMP(c.Request.URL.Path) {
|
||||
// Ensure proper authentication and authorization
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required for FedRAMP-compliant access"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Log the access for compliance monitoring
|
||||
log.Printf("FedRAMP Access: User %v accessed %s at %v", userID, c.Request.URL.Path, time.Now())
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// requiresFedRAMP checks if a path requires FedRAMP compliance
|
||||
func requiresFedRAMP(path string) bool {
|
||||
// In a real implementation, this would check against FedRAMP-protected resources
|
||||
// For now, we'll consider administrative paths as requiring FedRAMP compliance
|
||||
|
||||
protectedPaths := []string{
|
||||
"/api/v1/admin",
|
||||
"/api/v1/users",
|
||||
"/api/v1/audit",
|
||||
}
|
||||
|
||||
for _, protectedPath := range protectedPaths {
|
||||
if strings.HasPrefix(path, protectedPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// JWTAuthorizationMiddleware validates JWT tokens and ensures the user has required permissions
|
||||
func JWTAuthorizationMiddleware(requiredPermissions ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if tokenString == authHeader {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Bearer token required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Parse and validate the token
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte("supersecretkeyforjwt"), nil // In real implementation, use config
|
||||
})
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Extract claims
|
||||
if claims, ok := token.Claims.(jwt.MapClaims); ok {
|
||||
// Extract user ID from claims
|
||||
if userIDStr, ok := claims["user_id"].(string); ok {
|
||||
userID, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user ID in token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Store user ID in context for use in handlers
|
||||
c.Set("user_id", userID)
|
||||
|
||||
// Check permissions if required
|
||||
if len(requiredPermissions) > 0 {
|
||||
userRole, ok := claims["role"].(string)
|
||||
if !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Role not found in token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if !hasPermission(userRole, requiredPermissions) {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Insufficient permissions",
|
||||
"required_permissions": requiredPermissions,
|
||||
"role": userRole,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User ID not found in token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// hasPermission checks if a user role has the required permissions
|
||||
func hasPermission(userRole string, requiredPermissions []string) bool {
|
||||
// In a real implementation, this would check permissions database
|
||||
// For now, we'll implement a simple role-based permission system:
|
||||
// admin: can access everything
|
||||
// job_provider: can create positions, manage applications
|
||||
// job_seeker: can apply to positions, upload resumes
|
||||
|
||||
if userRole == "admin" {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, perm := range requiredPermissions {
|
||||
switch perm {
|
||||
case "create_position", "manage_applications":
|
||||
if userRole == "job_provider" || userRole == "admin" {
|
||||
return true
|
||||
}
|
||||
case "apply_to_position", "upload_resume":
|
||||
if userRole == "job_seeker" || userRole == "admin" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
582
qwen/go/services/services.go
Normal file
582
qwen/go/services/services.go
Normal file
@@ -0,0 +1,582 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mohportal/db"
|
||||
"mohportal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TenantService handles tenant-related operations
|
||||
type TenantService struct{}
|
||||
|
||||
// CreateTenant creates a new tenant
|
||||
func (ts *TenantService) CreateTenant(name, slug, description, logoURL string) (*models.Tenant, error) {
|
||||
tenant := &models.Tenant{
|
||||
ID: uuid.New(),
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
Description: description,
|
||||
LogoURL: logoURL,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := db.DB.Create(tenant).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tenant, nil
|
||||
}
|
||||
|
||||
// GetTenant retrieves a tenant by ID
|
||||
func (ts *TenantService) GetTenant(id uuid.UUID) (*models.Tenant, error) {
|
||||
var tenant models.Tenant
|
||||
if err := db.DB.First(&tenant, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("tenant not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &tenant, nil
|
||||
}
|
||||
|
||||
// GetTenantBySlug retrieves a tenant by slug
|
||||
func (ts *TenantService) GetTenantBySlug(slug string) (*models.Tenant, error) {
|
||||
var tenant models.Tenant
|
||||
if err := db.DB.First(&tenant, "slug = ?", slug).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("tenant not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &tenant, nil
|
||||
}
|
||||
|
||||
// GetTenants retrieves all tenants (with optional filtering)
|
||||
func (ts *TenantService) GetTenants(limit, offset int) ([]*models.Tenant, error) {
|
||||
var tenants []*models.Tenant
|
||||
query := db.DB.Limit(limit).Offset(offset)
|
||||
|
||||
if err := query.Find(&tenants).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tenants, nil
|
||||
}
|
||||
|
||||
// UpdateTenant updates an existing tenant
|
||||
func (ts *TenantService) UpdateTenant(id uuid.UUID, name, slug, description, logoURL string) (*models.Tenant, error) {
|
||||
var tenant models.Tenant
|
||||
if err := db.DB.First(&tenant, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tenant.Name = name
|
||||
tenant.Slug = slug
|
||||
tenant.Description = description
|
||||
tenant.LogoURL = logoURL
|
||||
tenant.UpdatedAt = time.Now()
|
||||
|
||||
if err := db.DB.Save(&tenant).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &tenant, nil
|
||||
}
|
||||
|
||||
// DeleteTenant deletes a tenant
|
||||
func (ts *TenantService) DeleteTenant(id uuid.UUID) error {
|
||||
return db.DB.Delete(&models.Tenant{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// UserService handles user-related operations
|
||||
type UserService struct{}
|
||||
|
||||
// CreateUser creates a new user
|
||||
func (us *UserService) CreateUser(tenantID uuid.UUID, email, username, firstName, lastName, phone, role, password string) (*models.User, error) {
|
||||
// Check if user already exists
|
||||
var existingUser models.User
|
||||
if err := db.DB.Where("email = ? OR username = ?", email, username).First(&existingUser).Error; err == nil {
|
||||
return nil, errors.New("user with email or username already exists")
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
ID: uuid.New(),
|
||||
TenantID: tenantID,
|
||||
Email: email,
|
||||
Username: username,
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
Phone: phone,
|
||||
Role: role,
|
||||
PasswordHash: string(hashedPassword),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := db.DB.Create(user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetUser retrieves a user by ID
|
||||
func (us *UserService) GetUser(id uuid.UUID) (*models.User, error) {
|
||||
var user models.User
|
||||
if err := db.DB.Preload("Tenant").First(&user, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetUserByEmail retrieves a user by email
|
||||
func (us *UserService) GetUserByEmail(email string) (*models.User, error) {
|
||||
var user models.User
|
||||
if err := db.DB.Preload("Tenant").First(&user, "email = ?", email).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// AuthenticateUser authenticates a user by email and password
|
||||
func (us *UserService) AuthenticateUser(email, password string) (*models.User, error) {
|
||||
var user models.User
|
||||
if err := db.DB.First(&user, "email = ?", email).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// Return error without specifying whether email exists to prevent timing attacks
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
return nil, errors.New("user account is deactivated")
|
||||
}
|
||||
|
||||
// Compare the password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
// Update last login
|
||||
user.LastLogin = &time.Now()
|
||||
db.DB.Save(&user)
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// UpdateUser updates an existing user
|
||||
func (us *UserService) UpdateUser(id uuid.UUID, email, username, firstName, lastName, phone, role string) (*models.User, error) {
|
||||
var user models.User
|
||||
if err := db.DB.First(&user, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if email or username is already taken by another user
|
||||
var existingUser models.User
|
||||
if err := db.DB.Where("email = ? AND id != ?", email, id).First(&existingUser).Error; err == nil {
|
||||
return nil, errors.New("email already in use by another user")
|
||||
}
|
||||
|
||||
if username != "" {
|
||||
if err := db.DB.Where("username = ? AND id != ?", username, id).First(&existingUser).Error; err == nil {
|
||||
return nil, errors.New("username already in use by another user")
|
||||
}
|
||||
}
|
||||
|
||||
user.Email = email
|
||||
if username != "" {
|
||||
user.Username = username
|
||||
}
|
||||
user.FirstName = firstName
|
||||
user.LastName = lastName
|
||||
user.Phone = phone
|
||||
user.Role = role
|
||||
user.UpdatedAt = time.Now()
|
||||
|
||||
if err := db.DB.Save(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// PositionService handles job position-related operations
|
||||
type PositionService struct{}
|
||||
|
||||
// CreatePosition creates a new job position
|
||||
func (ps *PositionService) CreatePosition(tenantID, userID uuid.UUID, title, description, requirements, location, employmentType, experienceLevel string, salaryMin, salaryMax *float64) (*models.JobPosition, error) {
|
||||
position := &models.JobPosition{
|
||||
ID: uuid.New(),
|
||||
TenantID: tenantID,
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
Description: description,
|
||||
Requirements: requirements,
|
||||
Location: location,
|
||||
EmploymentType: employmentType,
|
||||
SalaryMin: salaryMin,
|
||||
SalaryMax: salaryMax,
|
||||
ExperienceLevel: experienceLevel,
|
||||
PostedAt: time.Now(),
|
||||
Status: "open",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := db.DB.Create(position).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return position, nil
|
||||
}
|
||||
|
||||
// GetPosition retrieves a position by ID
|
||||
func (ps *PositionService) GetPosition(id uuid.UUID) (*models.JobPosition, error) {
|
||||
var position models.JobPosition
|
||||
if err := db.DB.Preload("User").First(&position, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("position not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &position, nil
|
||||
}
|
||||
|
||||
// GetPositions retrieves positions with optional filtering
|
||||
func (ps *PositionService) GetPositions(tenantID *uuid.UUID, limit, offset int, status, employmentType, experienceLevel, location string) ([]*models.JobPosition, error) {
|
||||
var positions []*models.JobPosition
|
||||
query := db.DB.Preload("User").Limit(limit).Offset(offset)
|
||||
|
||||
if tenantID != nil {
|
||||
query = query.Where("tenant_id = ?", tenantID)
|
||||
}
|
||||
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
if employmentType != "" {
|
||||
query = query.Where("employment_type = ?", employmentType)
|
||||
}
|
||||
|
||||
if experienceLevel != "" {
|
||||
query = query.Where("experience_level = ?", experienceLevel)
|
||||
}
|
||||
|
||||
if location != "" {
|
||||
query = query.Where("location LIKE ?", "%"+location+"%")
|
||||
}
|
||||
|
||||
query = query.Where("is_active = ? AND status = ?", true, "open")
|
||||
|
||||
if err := query.Find(&positions).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
// UpdatePosition updates an existing position
|
||||
func (ps *PositionService) UpdatePosition(id uuid.UUID, title, description, requirements, location, employmentType, experienceLevel string, salaryMin, salaryMax *float64) (*models.JobPosition, error) {
|
||||
var position models.JobPosition
|
||||
if err := db.DB.First(&position, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
position.Title = title
|
||||
position.Description = description
|
||||
position.Requirements = requirements
|
||||
position.Location = location
|
||||
position.EmploymentType = employmentType
|
||||
position.ExperienceLevel = experienceLevel
|
||||
position.SalaryMin = salaryMin
|
||||
position.SalaryMax = salaryMax
|
||||
position.UpdatedAt = time.Now()
|
||||
|
||||
if err := db.DB.Save(&position).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &position, nil
|
||||
}
|
||||
|
||||
// ClosePosition closes a position (marks as filled or cancelled)
|
||||
func (ps *PositionService) ClosePosition(id uuid.UUID, status string) (*models.JobPosition, error) {
|
||||
var position models.JobPosition
|
||||
if err := db.DB.First(&position, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if status != "closed" && status != "filled" {
|
||||
return nil, errors.New("invalid status for closing position")
|
||||
}
|
||||
|
||||
position.Status = status
|
||||
position.ClosedAt = &time.Now()
|
||||
position.UpdatedAt = time.Now()
|
||||
|
||||
if err := db.DB.Save(&position).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &position, nil
|
||||
}
|
||||
|
||||
// DeletePosition deletes a position
|
||||
func (ps *PositionService) DeletePosition(id uuid.UUID) error {
|
||||
return db.DB.Delete(&models.JobPosition{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// ResumeService handles resume-related operations
|
||||
type ResumeService struct{}
|
||||
|
||||
// UploadResume uploads a resume file and creates a record
|
||||
func (rs *ResumeService) UploadResume(userID uuid.UUID, fileHeader *multipart.FileHeader, title string) (*models.Resume, error) {
|
||||
// Validate file type
|
||||
allowedTypes := map[string]bool{
|
||||
"application/pdf": true,
|
||||
"application/msword": true,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
|
||||
"text/plain": true,
|
||||
}
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Detect content type
|
||||
buffer := make([]byte, 512)
|
||||
_, err = file.Read(buffer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fileType := http.DetectContentType(buffer)
|
||||
if !allowedTypes[fileType] {
|
||||
return nil, errors.New("file type not allowed")
|
||||
}
|
||||
|
||||
// Ensure the static/uploads directory exists
|
||||
uploadDir := "./static/uploads/resumes"
|
||||
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
filename := fmt.Sprintf("%s_%s", userID.String(), fileHeader.Filename)
|
||||
uploadPath := filepath.Join(uploadDir, filename)
|
||||
|
||||
// Copy file to upload directory
|
||||
src, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, err := os.Create(uploadPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create resume record
|
||||
resume := &models.Resume{
|
||||
ID: uuid.New(),
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
FilePath: uploadPath,
|
||||
FileType: fileType,
|
||||
FileSize: fileHeader.Size,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := db.DB.Create(resume).Error; err != nil {
|
||||
// Clean up the uploaded file if DB operation fails
|
||||
os.Remove(uploadPath)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resume, nil
|
||||
}
|
||||
|
||||
// GetResume retrieves a resume by ID
|
||||
func (rs *ResumeService) GetResume(id uuid.UUID) (*models.Resume, error) {
|
||||
var resume models.Resume
|
||||
if err := db.DB.First(&resume, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("resume not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &resume, nil
|
||||
}
|
||||
|
||||
// GetResumeByUser retrieves all resumes for a user
|
||||
func (rs *ResumeService) GetResumeByUser(userID uuid.UUID) ([]*models.Resume, error) {
|
||||
var resumes []*models.Resume
|
||||
if err := db.DB.Where("user_id = ?", userID).Find(&resumes).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resumes, nil
|
||||
}
|
||||
|
||||
// ApplicationService handles job application-related operations
|
||||
type ApplicationService struct{}
|
||||
|
||||
// CreateApplication creates a new job application
|
||||
func (as *ApplicationService) CreateApplication(positionID, userID uuid.UUID, resumeID *uuid.UUID, coverLetter string) (*models.Application, error) {
|
||||
// Check if user already applied for this position
|
||||
var existingApplication models.Application
|
||||
if err := db.DB.Where("position_id = ? AND user_id = ?", positionID, userID).First(&existingApplication).Error; err == nil {
|
||||
return nil, errors.New("user has already applied for this position")
|
||||
}
|
||||
|
||||
// Verify position exists and is open
|
||||
var position models.JobPosition
|
||||
if err := db.DB.First(&position, "id = ? AND status = ? AND is_active = ?", positionID, "open", true).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("position not found or not open")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
application := &models.Application{
|
||||
ID: uuid.New(),
|
||||
PositionID: positionID,
|
||||
UserID: userID,
|
||||
ResumeID: resumeID,
|
||||
CoverLetter: coverLetter,
|
||||
Status: "pending",
|
||||
AppliedAt: time.Now(),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := db.DB.Create(application).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return application, nil
|
||||
}
|
||||
|
||||
// GetApplication retrieves an application by ID
|
||||
func (as *ApplicationService) GetApplication(id uuid.UUID) (*models.Application, error) {
|
||||
var application models.Application
|
||||
if err := db.DB.Preload("User").Preload("Position").Preload("Resume").First(&application, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("application not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &application, nil
|
||||
}
|
||||
|
||||
// GetApplications retrieves applications with optional filtering
|
||||
func (as *ApplicationService) GetApplications(userID, positionID *uuid.UUID, status string, limit, offset int) ([]*models.Application, error) {
|
||||
var applications []*models.Application
|
||||
query := db.DB.Preload("User").Preload("Position").Preload("Resume").Limit(limit).Offset(offset)
|
||||
|
||||
if userID != nil {
|
||||
query = query.Where("user_id = ?", userID)
|
||||
}
|
||||
|
||||
if positionID != nil {
|
||||
query = query.Where("position_id = ?", positionID)
|
||||
}
|
||||
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
if err := query.Find(&applications).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return applications, nil
|
||||
}
|
||||
|
||||
// UpdateApplication updates an application status
|
||||
func (as *ApplicationService) UpdateApplication(id uuid.UUID, status string, reviewerID uuid.UUID, notes string) (*models.Application, error) {
|
||||
var application models.Application
|
||||
if err := db.DB.First(&application, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate status
|
||||
if !models.ValidStatus(status) {
|
||||
return nil, errors.New("invalid status")
|
||||
}
|
||||
|
||||
application.Status = status
|
||||
application.ReviewerUserID = &reviewerID
|
||||
application.Notes = notes
|
||||
application.UpdatedAt = time.Now()
|
||||
application.ReviewedAt = &time.Now()
|
||||
|
||||
if err := db.DB.Save(&application).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &application, nil
|
||||
}
|
||||
|
||||
// DeleteApplication deletes an application
|
||||
func (as *ApplicationService) DeleteApplication(id uuid.UUID) error {
|
||||
return db.DB.Delete(&models.Application{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// GetApplicationsForPosition retrieves all applications for a specific position
|
||||
func (as *ApplicationService) GetApplicationsForPosition(positionID uuid.UUID, status string, limit, offset int) ([]*models.Application, error) {
|
||||
var applications []*models.Application
|
||||
query := db.DB.Preload("User").Preload("Resume").Limit(limit).Offset(offset).Where("position_id = ?", positionID)
|
||||
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
if err := query.Find(&applications).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return applications, nil
|
||||
}
|
||||
38
qwen/go/start.sh
Executable file
38
qwen/go/start.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Startup script for MerchantsOfHope.org recruiting platform
|
||||
# This script helps run the application using Docker Compose
|
||||
|
||||
set -e
|
||||
|
||||
echo "MerchantsOfHope.org Recruiting Platform"
|
||||
echo "======================================="
|
||||
|
||||
# Check if Docker is available
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo "Error: Docker is not installed or not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if docker-compose is available
|
||||
if ! command -v docker-compose &> /dev/null; then
|
||||
echo "Error: Docker Compose is not installed or not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get the directory of this script
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
echo "Starting MerchantsOfHope.org recruiting platform..."
|
||||
echo "Project directory: $SCRIPT_DIR"
|
||||
|
||||
# Build and start the containers
|
||||
echo "Building and starting containers..."
|
||||
docker-compose up --build -d
|
||||
|
||||
echo "Containers started successfully!"
|
||||
echo "The application will be available at http://localhost:17000"
|
||||
echo "Keycloak admin interface will be available at http://localhost:8080"
|
||||
echo ""
|
||||
echo "To view logs: docker-compose logs -f"
|
||||
echo "To stop: docker-compose down"
|
||||
453
qwen/go/static/css/accessibility.css
Normal file
453
qwen/go/static/css/accessibility.css
Normal file
@@ -0,0 +1,453 @@
|
||||
/* Accessible CSS for MerchantsOfHope.org platform */
|
||||
|
||||
/* High contrast and accessibility-focused styles */
|
||||
:root {
|
||||
--primary-color: #1a365d; /* Darker blue for better contrast */
|
||||
--secondary-color: #2a5cb0; /* Standard blue */
|
||||
--accent-color: #b42f2f; /* Red for important elements */
|
||||
--light-color: #ffffff; /* Pure white for contrast */
|
||||
--dark-color: #000000; /* Pure black for text */
|
||||
--success-color: #1f7a1f; /* Darker green */
|
||||
--warning-color: #cc6600; /* Darker orange */
|
||||
--font-size-base: 18px; /* Larger base font for better readability */
|
||||
--font-size-large: 1.375rem; /* 22px */
|
||||
--font-size-xlarge: 1.75rem; /* 28px */
|
||||
--line-height: 1.6; /* Increased for readability */
|
||||
--border-radius: 0px; /* Remove rounded corners for accessibility */
|
||||
--spacing-small: 0.75rem;
|
||||
--spacing-medium: 1.25rem;
|
||||
--spacing-large: 1.75rem;
|
||||
}
|
||||
|
||||
/* Reset and base styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Semantic HTML elements */
|
||||
main,
|
||||
header,
|
||||
nav,
|
||||
footer,
|
||||
section,
|
||||
article,
|
||||
aside {
|
||||
display: block;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height);
|
||||
color: var(--dark-color);
|
||||
background-color: var(--light-color);
|
||||
padding: var(--spacing-medium);
|
||||
/* Ensure sufficient contrast */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Container for layout */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header styles */
|
||||
header {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--light-color);
|
||||
padding: var(--spacing-medium);
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: var(--spacing-large);
|
||||
/* Ensure sufficient contrast */
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
nav ul {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
gap: var(--spacing-medium);
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: var(--light-color);
|
||||
text-decoration: underline; /* Always show underlines for clarity */
|
||||
padding: var(--spacing-small);
|
||||
border-radius: var(--border-radius);
|
||||
transition: background-color 0.3s;
|
||||
/* Ensure sufficient contrast */
|
||||
}
|
||||
|
||||
nav a:hover, nav a:focus {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
outline: 3px solid var(--light-color); /* Thicker outline for focus */
|
||||
outline-offset: 2px;
|
||||
/* Ensure link text remains readable */
|
||||
color: var(--light-color);
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
main {
|
||||
margin: var(--spacing-large) 0;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-bottom: var(--spacing-small);
|
||||
color: var(--primary-color);
|
||||
line-height: 1.2;
|
||||
/* Ensure font weights are appropriate */
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--font-size-xlarge);
|
||||
margin-bottom: var(--spacing-medium);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(var(--font-size-large) * 1.2);
|
||||
border-bottom: 3px solid var(--secondary-color);
|
||||
padding-bottom: var(--spacing-small);
|
||||
margin-top: var(--spacing-large);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-small) var(--spacing-medium);
|
||||
background-color: var(--secondary-color);
|
||||
color: var(--light-color);
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600; /* Bold for better readability */
|
||||
transition: background-color 0.3s;
|
||||
margin: var(--spacing-small);
|
||||
/* Ensure sufficient contrast */
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn:hover, .btn:focus {
|
||||
background-color: #1d4e89; /* Darker shade on hover */
|
||||
outline: 3px solid var(--dark-color); /* Thick outline for focus */
|
||||
outline-offset: 2px;
|
||||
/* Maintain readability */
|
||||
color: var(--light-color);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--success-color);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-medium);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-small);
|
||||
font-weight: 600; /* Bold for better readability */
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
padding: var(--spacing-small);
|
||||
border: 2px solid #333; /* Thicker border for visibility */
|
||||
border-radius: var(--border-radius);
|
||||
font-size: var(--font-size-base);
|
||||
/* Ensure sufficient contrast */
|
||||
background-color: var(--light-color);
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: 3px solid var(--secondary-color); /* Thick outline for focus */
|
||||
outline-offset: 0;
|
||||
/* Maintain readable text */
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: var(--spacing-medium) 0;
|
||||
/* Ensure tables are readable */
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: var(--spacing-small);
|
||||
text-align: left;
|
||||
border-bottom: 2px solid #333; /* Thicker border for visibility */
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--light-color);
|
||||
font-weight: 600; /* Bold header text */
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: rgba(42, 92, 176, 0.1); /* Subtle highlight */
|
||||
}
|
||||
|
||||
/* Cards and sections */
|
||||
.card {
|
||||
background: var(--light-color);
|
||||
border: 2px solid #333; /* Thicker border for visibility */
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--spacing-medium);
|
||||
margin: var(--spacing-medium) 0;
|
||||
/* Ensure sufficient contrast */
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
padding: var(--spacing-medium);
|
||||
border-radius: var(--border-radius);
|
||||
margin: var(--spacing-medium) 0;
|
||||
border: 2px solid; /* Thicker border */
|
||||
font-weight: 600; /* Bold text */
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: #d4f6d4; /* Light green */
|
||||
color: var(--success-color);
|
||||
border-color: var(--success-color);
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: #fddddd; /* Light red */
|
||||
color: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Skip link for keyboard navigation */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 6px;
|
||||
background: var(--primary-color);
|
||||
color: var(--light-color);
|
||||
padding: 8px;
|
||||
text-decoration: underline;
|
||||
border-radius: var(--border-radius);
|
||||
z-index: 1000;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 6px;
|
||||
outline: 3px solid var(--light-color);
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--light-color);
|
||||
padding: var(--spacing-large);
|
||||
margin-top: var(--spacing-large);
|
||||
border-radius: var(--border-radius);
|
||||
text-align: center;
|
||||
/* Ensure sufficient contrast */
|
||||
box-shadow: 0 -4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Additional accessibility features */
|
||||
|
||||
/* Focus indicators for all interactive elements */
|
||||
button:focus,
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus,
|
||||
a:focus {
|
||||
outline: 3px solid var(--secondary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Ensure proper spacing between elements for users with motor disabilities */
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
a {
|
||||
margin: var(--spacing-small);
|
||||
min-height: 44px; /* Minimum touch target size */
|
||||
}
|
||||
|
||||
/* Link styling */
|
||||
a {
|
||||
color: var(--secondary-color);
|
||||
text-decoration: underline;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:focus {
|
||||
outline: 3px solid var(--secondary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
nav ul {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: var(--spacing-small) 0;
|
||||
}
|
||||
|
||||
/* Increase font size on touch devices */
|
||||
body {
|
||||
font-size: calc(var(--font-size-base) * 1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
body {
|
||||
font-size: 12pt;
|
||||
}
|
||||
|
||||
header,
|
||||
footer,
|
||||
nav,
|
||||
.skip-link {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion for users with vestibular disorders */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
/* Remove hover effects */
|
||||
.btn:hover,
|
||||
nav a:hover {
|
||||
background-color: transparent;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
:root {
|
||||
--primary-color: #000000;
|
||||
--secondary-color: #000080;
|
||||
--accent-color: #800000;
|
||||
--light-color: #ffffff;
|
||||
--dark-color: #000000;
|
||||
--success-color: #006400;
|
||||
--warning-color: #804000;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--light-color);
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 2px solid var(--dark-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure proper semantics for screen readers */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
/* Focus styles for custom components */
|
||||
.focusable {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.focusable:focus {
|
||||
outline: 3px solid var(--secondary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Ensure sufficient color contrast for all text */
|
||||
.primary-text {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.secondary-text {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.accent-text {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.success-text {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
/* Responsive typography */
|
||||
html {
|
||||
font-size: 100%; /* Sets base font size to browser default */
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.5rem, 4vw, 2.5rem);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: clamp(1.3rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
/* Animation for loading states */
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.loading {
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
15
qwen/go/stop.sh
Executable file
15
qwen/go/stop.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Stop script for MerchantsOfHope.org recruiting platform
|
||||
|
||||
set -e
|
||||
|
||||
echo "Stopping MerchantsOfHope.org recruiting platform..."
|
||||
|
||||
# Get the directory of this script
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Stop the containers
|
||||
docker-compose down
|
||||
|
||||
echo "Containers stopped successfully!"
|
||||
206
qwen/go/templates/index.html
Normal file
206
qwen/go/templates/index.html
Normal file
@@ -0,0 +1,206 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MerchantsOfHope.org - Connecting Talents with Opportunities</title>
|
||||
<meta name="description" content="A multi-tenant recruiting platform for TSYS Group's various business lines">
|
||||
|
||||
<!-- Accessibility features -->
|
||||
<meta name="theme-color" content="#2c3e50">
|
||||
|
||||
<link rel="stylesheet" href="/static/css/accessibility.css">
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main" class="skip-link">Skip to main content</a>
|
||||
|
||||
<header>
|
||||
<div class="container">
|
||||
<h1>MerchantsOfHope.org</h1>
|
||||
<p>Connecting talents with opportunities across TSYS Group</p>
|
||||
|
||||
<nav aria-label="Main navigation">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/positions">Browse Positions</a></li>
|
||||
<li><a href="/apply">Apply</a></li>
|
||||
<li><a href="/login">Login</a></li>
|
||||
<li><a href="/register">Register</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<div class="container">
|
||||
<section class="hero">
|
||||
<div class="card">
|
||||
<h2>Welcome to MerchantsOfHope.org</h2>
|
||||
<p>Our platform connects talented professionals with opportunities across TSYS Group's diverse business lines. Whether you're looking for your next career opportunity or seeking the perfect candidate, we provide a seamless, accessible experience for all users.</p>
|
||||
|
||||
<div class="actions">
|
||||
<a href="/positions" class="btn btn-primary">Browse Job Positions</a>
|
||||
<a href="/register" class="btn">Register as Job Seeker</a>
|
||||
<a href="/register?role=provider" class="btn">Register as Job Provider</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="features">
|
||||
<h2>Platform Features</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>Multi-Tenant Architecture</h3>
|
||||
<p>Each of TSYS Group's business lines operates as an independent tenant with complete data isolation, ensuring privacy and security across all operations.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Universal Access</h3>
|
||||
<p>Our platform is designed with accessibility in mind, following WCAG 2.1 AA standards to ensure everyone can participate regardless of ability.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Secure Authentication</h3>
|
||||
<p>Log in using your preferred method - local credentials, OIDC, or social media accounts - with enterprise-grade security measures in place.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="positions-preview">
|
||||
<h2>Featured Positions</h2>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Location</th>
|
||||
<th>Type</th>
|
||||
<th>Posted</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="positions-table-body">
|
||||
<tr>
|
||||
<td colspan="5">Loading positions...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<a href="/positions" class="btn">View All Positions</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2025 MerchantsOfHope.org - A TSYS Group Initiative</p>
|
||||
<p>Committed to accessibility, security, and excellence</p>
|
||||
<p>
|
||||
<a href="/accessibility" style="color: white; margin: 0 10px;">Accessibility Statement</a> |
|
||||
<a href="/privacy" style="color: white; margin: 0 10px;">Privacy Policy</a> |
|
||||
<a href="/terms" style="color: white; margin: 0 10px;">Terms of Service</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Accessible JavaScript functionality
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Load featured positions
|
||||
loadFeaturedPositions();
|
||||
|
||||
// Set proper focus management
|
||||
const mainContent = document.getElementById('main');
|
||||
mainContent.setAttribute('tabindex', '-1');
|
||||
|
||||
// Focus main content when page loads
|
||||
mainContent.focus();
|
||||
|
||||
// Form validation
|
||||
const forms = document.querySelectorAll('form');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Basic validation
|
||||
let isValid = true;
|
||||
const requiredFields = form.querySelectorAll('[required]');
|
||||
|
||||
requiredFields.forEach(field => {
|
||||
if (!field.value.trim()) {
|
||||
isValid = false;
|
||||
field.classList.add('error');
|
||||
|
||||
// Create error message
|
||||
const error = document.createElement('div');
|
||||
error.className = 'alert alert-error';
|
||||
error.textContent = `${field.name || 'This field'} is required`;
|
||||
error.setAttribute('role', 'alert');
|
||||
|
||||
// Add to proper location
|
||||
field.parentNode.insertBefore(error, field.nextSibling);
|
||||
} else {
|
||||
field.classList.remove('error');
|
||||
|
||||
// Remove any existing error messages
|
||||
const existingErrors = field.parentNode.querySelectorAll('.alert-error');
|
||||
existingErrors.forEach(err => err.remove());
|
||||
}
|
||||
});
|
||||
|
||||
if (isValid) {
|
||||
// In a real app, you would submit the form here
|
||||
alert('Form submitted successfully! (Demo)');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Ensure all interactive elements have proper focus management
|
||||
const interactiveElements = document.querySelectorAll('a, button, input, select, textarea');
|
||||
interactiveElements.forEach(el => {
|
||||
el.addEventListener('focus', function() {
|
||||
this.style.outline = '2px solid var(--secondary-color)';
|
||||
this.style.outlineOffset = '2px';
|
||||
});
|
||||
|
||||
el.addEventListener('blur', function() {
|
||||
this.style.outline = '';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function loadFeaturedPositions() {
|
||||
// In a real app, this would fetch from the API
|
||||
// For demo, we'll use sample data
|
||||
const positions = [
|
||||
{ title: "Senior Software Engineer", location: "Remote", type: "Full-time", posted: "2025-01-15" },
|
||||
{ title: "DevOps Specialist", location: "Atlanta, GA", type: "Contract", posted: "2025-01-10" },
|
||||
{ title: "Product Manager", location: "Remote", type: "Full-time", posted: "2025-01-05" },
|
||||
{ title: "UX/UI Designer", location: "New York, NY", type: "Full-time", posted: "2025-01-01" }
|
||||
];
|
||||
|
||||
const tbody = document.getElementById('positions-table-body');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
positions.forEach(position => {
|
||||
const row = document.createElement('tr');
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${position.title}</td>
|
||||
<td>${position.location}</td>
|
||||
<td>${position.type}</td>
|
||||
<td>${position.posted}</td>
|
||||
<td>
|
||||
<button class="btn" onclick="viewPosition('${position.title}')">View Details</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function viewPosition(title) {
|
||||
alert(`Viewing position: ${title}\n\nIn a real application, this would take you to the detailed position page.`);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
156
qwen/go/templates/login.html
Normal file
156
qwen/go/templates/login.html
Normal file
@@ -0,0 +1,156 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - MerchantsOfHope.org</title>
|
||||
<meta name="description" content="Login to your MerchantsOfHope.org account">
|
||||
|
||||
<link rel="stylesheet" href="/static/css/accessibility.css">
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main" class="skip-link">Skip to main content</a>
|
||||
|
||||
<header>
|
||||
<div class="container">
|
||||
<h1>MerchantsOfHope.org</h1>
|
||||
<p>Login to Your Account</p>
|
||||
|
||||
<nav aria-label="Main navigation">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/positions">Browse Positions</a></li>
|
||||
<li><a href="/apply">Apply</a></li>
|
||||
<li><a href="/login">Login</a></li>
|
||||
<li><a href="/register">Register</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<div class="container">
|
||||
<h1>Login</h1>
|
||||
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input type="email" id="email" name="email" required aria-describedby="email-help">
|
||||
<div id="email-help" class="form-hint">Enter your registered email address</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required aria-describedby="password-help">
|
||||
<div id="password-help" class="form-hint">Enter your password</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</form>
|
||||
|
||||
<div class="divider">
|
||||
<span>Or continue with</span>
|
||||
</div>
|
||||
|
||||
<div class="social-login">
|
||||
<button class="social-btn" onclick="socialLogin('oidc')" aria-label="Login with enterprise SSO">
|
||||
<span>🏢</span> <span>Enterprise SSO (OIDC)</span>
|
||||
</button>
|
||||
<button class="social-btn" onclick="socialLogin('google')" aria-label="Login with Google">
|
||||
<span>🔍</span> <span>Continue with Google</span>
|
||||
</button>
|
||||
<button class="social-btn" onclick="socialLogin('github')" aria-label="Login with GitHub">
|
||||
<span>🐱</span> <span>Continue with GitHub</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top: var(--spacing-large);">
|
||||
<p>Don't have an account? <a href="/register">Register here</a></p>
|
||||
<p><a href="/forgot-password">Forgot your password?</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2025 MerchantsOfHope.org - A TSYS Group Initiative</p>
|
||||
<p>Committed to accessibility, security, and excellence</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Set proper focus management
|
||||
const mainContent = document.getElementById('main');
|
||||
mainContent.setAttribute('tabindex', '-1');
|
||||
mainContent.focus();
|
||||
|
||||
// Login form submission
|
||||
const loginForm = document.getElementById('login-form');
|
||||
loginForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Get form data
|
||||
const email = document.getElementById('email').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
// Basic validation
|
||||
if (!email || !password) {
|
||||
showError('Please enter both email and password');
|
||||
return;
|
||||
}
|
||||
|
||||
// In a real app, this would make an API call to authenticate the user
|
||||
alert(`Attempting to login with email: ${email}\n\nIn a real application, this would call the authentication API.`);
|
||||
|
||||
// Simulate login success
|
||||
localStorage.setItem('isLoggedIn', 'true');
|
||||
localStorage.setItem('userEmail', email);
|
||||
|
||||
// Redirect to dashboard or previous page
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
// Ensure all interactive elements have proper focus management
|
||||
const interactiveElements = document.querySelectorAll('a, button, input, select, textarea');
|
||||
interactiveElements.forEach(el => {
|
||||
el.addEventListener('focus', function() {
|
||||
this.style.outline = '2px solid var(--secondary-color)';
|
||||
this.style.outlineOffset = '2px';
|
||||
});
|
||||
|
||||
el.addEventListener('blur', function() {
|
||||
this.style.outline = '';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function socialLogin(provider) {
|
||||
// In a real app, this would redirect to the appropriate OAuth provider
|
||||
alert(`Redirecting to ${provider} authentication. In a real application, this would redirect to the OAuth provider.`);
|
||||
|
||||
// Store return URL to redirect after authentication
|
||||
localStorage.setItem('returnUrl', window.location.href);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
// Create error message element
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'alert alert-error';
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.setAttribute('role', 'alert');
|
||||
|
||||
// Add to beginning of form
|
||||
const form = document.getElementById('login-form');
|
||||
form.insertBefore(errorDiv, form.firstChild);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (errorDiv.parentNode) {
|
||||
errorDiv.parentNode.removeChild(errorDiv);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
222
qwen/go/templates/positions.html
Normal file
222
qwen/go/templates/positions.html
Normal file
@@ -0,0 +1,222 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Browse Positions - MerchantsOfHope.org</title>
|
||||
<meta name="description" content="Browse job positions on MerchantsOfHope.org">
|
||||
|
||||
<link rel="stylesheet" href="/static/css/accessibility.css">
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main" class="skip-link">Skip to main content</a>
|
||||
|
||||
<header>
|
||||
<div class="container">
|
||||
<h1>MerchantsOfHope.org</h1>
|
||||
<p>Browse Job Positions</p>
|
||||
|
||||
<nav aria-label="Main navigation">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/positions">Browse Positions</a></li>
|
||||
<li><a href="/apply">Apply</a></li>
|
||||
<li><a href="/login">Login</a></li>
|
||||
<li><a href="/register">Register</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<div class="container">
|
||||
<h1>Available Positions</h1>
|
||||
|
||||
<div class="filter-section">
|
||||
<h2>Filter Positions</h2>
|
||||
<form id="filter-form">
|
||||
<div class="form-group">
|
||||
<label for="location">Location</label>
|
||||
<input type="text" id="location" name="location" placeholder="e.g., Remote, New York, Atlanta">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="employment-type">Employment Type</label>
|
||||
<select id="employment-type" name="employment_type">
|
||||
<option value="">All Types</option>
|
||||
<option value="full_time">Full-time</option>
|
||||
<option value="part_time">Part-time</option>
|
||||
<option value="contract">Contract</option>
|
||||
<option value="internship">Internship</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="experience-level">Experience Level</label>
|
||||
<select id="experience-level" name="experience_level">
|
||||
<option value="">All Levels</option>
|
||||
<option value="entry_level">Entry Level</option>
|
||||
<option value="mid_level">Mid Level</option>
|
||||
<option value="senior_level">Senior Level</option>
|
||||
<option value="executive">Executive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Apply Filters</button>
|
||||
<button type="reset" class="btn">Clear Filters</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="positions-container">
|
||||
<!-- Positions will be loaded here dynamically -->
|
||||
<div class="position-card">
|
||||
<div class="position-title">Senior Software Engineer</div>
|
||||
<div class="position-meta">
|
||||
<div>💰 $100,000 - $140,000</div>
|
||||
<div>📍 Remote</div>
|
||||
<div>🕒 Full-time</div>
|
||||
<div>📊 Mid Level</div>
|
||||
</div>
|
||||
<p>Join our dynamic team building cutting-edge financial solutions. We're looking for an experienced software engineer with strong Go skills to help develop our next-generation platform.</p>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick="applyForPosition('Senior Software Engineer')">Apply Now</button>
|
||||
<button class="btn" onclick="viewPositionDetails('Senior Software Engineer')">View Details</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="position-card">
|
||||
<div class="position-title">DevOps Specialist</div>
|
||||
<div class="position-meta">
|
||||
<div>💰 $90,000 - $120,000</div>
|
||||
<div>📍 Atlanta, GA</div>
|
||||
<div>🕒 Contract</div>
|
||||
<div>📊 Mid Level</div>
|
||||
</div>
|
||||
<p>Help us improve our deployment pipelines and infrastructure. We need a DevOps specialist to implement CI/CD solutions and ensure our systems are scalable and reliable.</p>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick="applyForPosition('DevOps Specialist')">Apply Now</button>
|
||||
<button class="btn" onclick="viewPositionDetails('DevOps Specialist')">View Details</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="position-card">
|
||||
<div class="position-title">Product Manager</div>
|
||||
<div class="position-meta">
|
||||
<div>💰 $95,000 - $130,000</div>
|
||||
<div>📍 Remote</div>
|
||||
<div>🕒 Full-time</div>
|
||||
<div>📊 Senior Level</div>
|
||||
</div>
|
||||
<p>Lead product strategy and development for our merchant services platform. This role requires excellent communication skills and a deep understanding of the fintech industry.</p>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick="applyForPosition('Product Manager')">Apply Now</button>
|
||||
<button class="btn" onclick="viewPositionDetails('Product Manager')">View Details</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pagination" class="card">
|
||||
<nav aria-label="Pagination">
|
||||
<ul style="display: flex; list-style: none; gap: var(--spacing-small);">
|
||||
<li><button class="btn" onclick="changePage(1)">First</button></li>
|
||||
<li><button class="btn" onclick="changePage(currentPage - 1)" id="prev-btn">Previous</button></li>
|
||||
<li><button class="btn" onclick="changePage(1)" aria-current="page" id="page-1">1</button></li>
|
||||
<li><button class="btn" onclick="changePage(2)" id="page-2">2</button></li>
|
||||
<li><button class="btn" onclick="changePage(3)" id="page-3">3</button></li>
|
||||
<li><button class="btn" onclick="changePage(currentPage + 1)" id="next-btn">Next</button></li>
|
||||
<li><button class="btn" onclick="changePage(10)">Last</button></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2025 MerchantsOfHope.org - A TSYS Group Initiative</p>
|
||||
<p>Committed to accessibility, security, and excellence</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Set proper focus management
|
||||
const mainContent = document.getElementById('main');
|
||||
mainContent.setAttribute('tabindex', '-1');
|
||||
mainContent.focus();
|
||||
|
||||
// Filter form submission
|
||||
const filterForm = document.getElementById('filter-form');
|
||||
filterForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
// Ensure all interactive elements have proper focus management
|
||||
const interactiveElements = document.querySelectorAll('a, button, input, select, textarea');
|
||||
interactiveElements.forEach(el => {
|
||||
el.addEventListener('focus', function() {
|
||||
this.style.outline = '2px solid var(--secondary-color)';
|
||||
this.style.outlineOffset = '2px';
|
||||
});
|
||||
|
||||
el.addEventListener('blur', function() {
|
||||
this.style.outline = '';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
let currentPage = 1;
|
||||
|
||||
function applyFilters() {
|
||||
// In a real app, this would update the position list based on filters
|
||||
alert('Filters applied! In a real application, this would update the position listings.');
|
||||
}
|
||||
|
||||
function applyForPosition(title) {
|
||||
// Check if user is logged in
|
||||
const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
|
||||
|
||||
if (!isLoggedIn) {
|
||||
alert('Please log in to apply for positions.');
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
alert(`Applying for position: ${title}\n\nIn a real application, this would take you to the application form.`);
|
||||
}
|
||||
|
||||
function viewPositionDetails(title) {
|
||||
alert(`Viewing details for position: ${title}\n\nIn a real application, this would show comprehensive details about the position.`);
|
||||
}
|
||||
|
||||
function changePage(page) {
|
||||
if (page < 1) page = 1;
|
||||
if (page > 10) page = 10;
|
||||
|
||||
currentPage = page;
|
||||
updatePagination();
|
||||
|
||||
// In a real app, this would load the new page of positions
|
||||
alert(`Loading page ${page} of positions`);
|
||||
}
|
||||
|
||||
function updatePagination() {
|
||||
document.getElementById('prev-btn').disabled = (currentPage === 1);
|
||||
document.getElementById('next-btn').disabled = (currentPage === 10);
|
||||
|
||||
// Update page number buttons to show current page
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const pageBtn = document.getElementById(`page-${i}`);
|
||||
if (i === currentPage) {
|
||||
pageBtn.setAttribute('aria-current', 'page');
|
||||
pageBtn.style.backgroundColor = 'var(--primary-color)';
|
||||
} else {
|
||||
pageBtn.removeAttribute('aria-current');
|
||||
pageBtn.style.backgroundColor = 'var(--secondary-color)';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
296
qwen/go/tests/tests.go
Normal file
296
qwen/go/tests/tests.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"mohportal/config"
|
||||
"mohportal/db"
|
||||
"mohportal/models"
|
||||
"mohportal/handlers"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var testDB *gorm.DB
|
||||
|
||||
func init() {
|
||||
// Initialize test database
|
||||
var err error
|
||||
testDB, err = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic("failed to connect to test database")
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
err = testDB.AutoMigrate(
|
||||
&models.Tenant{},
|
||||
&models.User{},
|
||||
&models.OIDCIdentity{},
|
||||
&models.SocialIdentity{},
|
||||
&models.JobPosition{},
|
||||
&models.Resume{},
|
||||
&models.Application{},
|
||||
)
|
||||
if err != nil {
|
||||
panic("failed to migrate test database")
|
||||
}
|
||||
|
||||
// Replace the main DB with test DB
|
||||
db.DB = testDB
|
||||
}
|
||||
|
||||
func setupRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
// Health check endpoint
|
||||
router.GET("/health", handlers.HealthCheck)
|
||||
|
||||
// API routes
|
||||
api := router.Group("/api/v1")
|
||||
{
|
||||
tenants := api.Group("/tenants")
|
||||
{
|
||||
tenants.POST("/", handlers.CreateTenant)
|
||||
tenants.GET("/", handlers.GetTenants)
|
||||
tenants.GET("/:id", handlers.GetTenant)
|
||||
}
|
||||
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
auth.POST("/login", handlers.Login)
|
||||
auth.POST("/register", handlers.Register)
|
||||
}
|
||||
|
||||
positions := api.Group("/positions")
|
||||
{
|
||||
positions.GET("/", handlers.GetPositions)
|
||||
positions.GET("/:id", handlers.GetPosition)
|
||||
positions.POST("/", handlers.CreatePosition)
|
||||
}
|
||||
|
||||
applications := api.Group("/applications")
|
||||
{
|
||||
applications.GET("/", handlers.GetApplications)
|
||||
applications.POST("/", handlers.CreateApplication)
|
||||
}
|
||||
|
||||
resumes := api.Group("/resumes")
|
||||
{
|
||||
resumes.POST("/", handlers.UploadResume)
|
||||
resumes.GET("/:id", handlers.GetResume)
|
||||
}
|
||||
}
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func TestHealthCheck(t *testing.T) {
|
||||
router := setupRouter()
|
||||
|
||||
req, _ := http.NewRequest("GET", "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "healthy", response["status"])
|
||||
}
|
||||
|
||||
func TestCreateTenant(t *testing.T) {
|
||||
router := setupRouter()
|
||||
|
||||
tenantData := map[string]string{
|
||||
"name": "Test Tenant",
|
||||
"slug": "test-tenant",
|
||||
"description": "A test tenant for testing purposes",
|
||||
}
|
||||
|
||||
jsonData, _ := json.Marshal(tenantData)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/tenants/", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
}
|
||||
|
||||
func TestGetTenants(t *testing.T) {
|
||||
router := setupRouter()
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/v1/tenants/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestCreateUser(t *testing.T) {
|
||||
// First create a tenant for the user
|
||||
var tenant models.Tenant
|
||||
tenantData := map[string]string{
|
||||
"name": "Test Tenant",
|
||||
"slug": "test-tenant-user",
|
||||
"description": "A test tenant for user testing",
|
||||
}
|
||||
|
||||
jsonData, _ := json.Marshal(tenantData)
|
||||
|
||||
router := setupRouter()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/tenants/", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
err := json.Unmarshal(w.Body.Bytes(), &tenant)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Now register a user
|
||||
userData := map[string]interface{}{
|
||||
"tenant_id": tenant.ID.String(),
|
||||
"email": "test@example.com",
|
||||
"username": "testuser",
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"phone": "1234567890",
|
||||
"role": "job_seeker",
|
||||
"password": "password123",
|
||||
}
|
||||
|
||||
jsonData, _ = json.Marshal(userData)
|
||||
req, _ = http.NewRequest("POST", "/api/v1/auth/register", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
}
|
||||
|
||||
func TestLogin(t *testing.T) {
|
||||
// First create a user for login test
|
||||
router := setupRouter()
|
||||
|
||||
// Create a tenant first
|
||||
tenantData := map[string]string{
|
||||
"name": "Test Tenant",
|
||||
"slug": "test-tenant-login",
|
||||
"description": "A test tenant for login testing",
|
||||
}
|
||||
jsonData, _ := json.Marshal(tenantData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/api/v1/tenants/", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
var tenant models.Tenant
|
||||
err := json.Unmarshal(w.Body.Bytes(), &tenant)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a user
|
||||
userData := map[string]interface{}{
|
||||
"tenant_id": tenant.ID.String(),
|
||||
"email": "login@example.com",
|
||||
"username": "loginuser",
|
||||
"first_name": "Login",
|
||||
"last_name": "User",
|
||||
"phone": "0987654321",
|
||||
"role": "job_seeker",
|
||||
"password": "password123",
|
||||
}
|
||||
jsonData, _ = json.Marshal(userData)
|
||||
|
||||
req, _ = http.NewRequest("POST", "/api/v1/auth/register", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
// Now try to login
|
||||
loginData := map[string]string{
|
||||
"email": "login@example.com",
|
||||
"password": "password123",
|
||||
}
|
||||
jsonData, _ = json.Marshal(loginData)
|
||||
|
||||
req, _ = http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestCreateJobPosition(t *testing.T) {
|
||||
router := setupRouter()
|
||||
|
||||
// Create a tenant and user first
|
||||
tenantData := map[string]string{
|
||||
"name": "Test Tenant",
|
||||
"slug": "test-tenant-position",
|
||||
"description": "A test tenant for position testing",
|
||||
}
|
||||
jsonData, _ := json.Marshal(tenantData)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/api/v1/tenants/", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
var tenant models.Tenant
|
||||
err := json.Unmarshal(w.Body.Bytes(), &tenant)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a user
|
||||
userData := map[string]interface{}{
|
||||
"tenant_id": tenant.ID.String(),
|
||||
"email": "position@example.com",
|
||||
"username": "positionuser",
|
||||
"first_name": "Position",
|
||||
"last_name": "User",
|
||||
"phone": "5555555555",
|
||||
"role": "job_provider",
|
||||
"password": "password123",
|
||||
}
|
||||
jsonData, _ = json.Marshal(userData)
|
||||
|
||||
req, _ = http.NewRequest("POST", "/api/v1/auth/register", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
// Now create a job position
|
||||
positionData := map[string]interface{}{
|
||||
"title": "Software Engineer",
|
||||
"description": "A software engineering position",
|
||||
"requirements": "3+ years of experience with Go",
|
||||
"location": "Remote",
|
||||
"employment_type": "full_time",
|
||||
"salary_min": 80000.0,
|
||||
"salary_max": 120000.0,
|
||||
"experience_level": "mid_level",
|
||||
}
|
||||
|
||||
jsonData, _ = json.Marshal(positionData)
|
||||
req, _ = http.NewRequest("POST", "/api/v1/positions/", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
}
|
||||
95
qwen/go/utils/utils.go
Normal file
95
qwen/go/utils/utils.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GenerateRandomString generates a random string of the specified length
|
||||
func GenerateRandomString(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// ValidateEmail validates an email address using a regex
|
||||
func ValidateEmail(email string) bool {
|
||||
re := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||
return re.MatchString(email)
|
||||
}
|
||||
|
||||
// SanitizeString removes potentially dangerous characters from a string
|
||||
func SanitizeString(input string) string {
|
||||
// Remove potentially dangerous characters/sequences
|
||||
sanitized := strings.ReplaceAll(input, "<", "")
|
||||
sanitized = strings.ReplaceAll(sanitized, ">", "")
|
||||
sanitized = strings.ReplaceAll(sanitized, "\"", "")
|
||||
sanitized = strings.ReplaceAll(sanitized, "'", "")
|
||||
sanitized = strings.ReplaceAll(sanitized, "\\", "")
|
||||
sanitized = strings.ReplaceAll(sanitized, "/", "")
|
||||
sanitized = strings.ReplaceAll(sanitized, ";", "")
|
||||
sanitized = strings.ReplaceAll(sanitized, "--", "")
|
||||
|
||||
return strings.TrimSpace(sanitized)
|
||||
}
|
||||
|
||||
// IsValidRole checks if a role is valid
|
||||
func IsValidRole(role string) bool {
|
||||
switch role {
|
||||
case "job_seeker", "job_provider", "admin":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsValidEmploymentType checks if an employment type is valid
|
||||
func IsValidEmploymentType(empType string) bool {
|
||||
switch empType {
|
||||
case "full_time", "part_time", "contract", "internship":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsValidExperienceLevel checks if an experience level is valid
|
||||
func IsValidExperienceLevel(level string) bool {
|
||||
switch level {
|
||||
case "entry_level", "mid_level", "senior_level", "executive":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsValidApplicationStatus checks if an application status is valid
|
||||
func IsValidApplicationStatus(status string) bool {
|
||||
switch status {
|
||||
case "pending", "reviewed", "accepted", "rejected", "open", "closed", "filled":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// FormatPhoneNumber standardizes a phone number
|
||||
func FormatPhoneNumber(phone string) string {
|
||||
// Remove all non-digit characters
|
||||
re := regexp.MustCompile(`\D`)
|
||||
digits := re.ReplaceAllString(phone, "")
|
||||
|
||||
// If it starts with country code, format accordingly
|
||||
if len(digits) == 11 && digits[0] == '1' {
|
||||
return fmt.Sprintf("(%s) %s-%s", digits[1:4], digits[4:7], digits[7:11])
|
||||
} else if len(digits) == 10 {
|
||||
return fmt.Sprintf("(%s) %s-%s", digits[0:3], digits[3:6], digits[6:10])
|
||||
}
|
||||
|
||||
return phone // Return original if can't format
|
||||
}
|
||||
Reference in New Issue
Block a user