mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-07-29 05:53:50 +00:00
first test
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM golang:1.21-alpine AS builder
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# YouTube Scraper Dockerfile
|
||||
FROM golang:1.21-alpine AS builder
|
||||
|
||||
# Install git and other build dependencies
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go mod files
|
||||
COPY youtube-scraper/go.mod youtube-scraper/go.sum ./
|
||||
|
||||
# Download dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY youtube-scraper/main.go ./
|
||||
|
||||
# Build the YouTube scraper
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o youtube-scraper main.go
|
||||
|
||||
# Final stage
|
||||
FROM alpine:latest
|
||||
|
||||
# Install ca-certificates for HTTPS requests
|
||||
RUN apk --no-cache add ca-certificates
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S scraper && \
|
||||
adduser -u 1001 -S scraper -G scraper
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the binary from builder stage
|
||||
COPY --from=builder /app/youtube-scraper .
|
||||
|
||||
# Change ownership to non-root user
|
||||
RUN chown scraper:scraper /app/youtube-scraper
|
||||
|
||||
# Switch to non-root user
|
||||
USER scraper
|
||||
|
||||
# Expose port
|
||||
EXPOSE 7857
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:7857/ || exit 1
|
||||
|
||||
# Run the YouTube scraper
|
||||
CMD ["./youtube-scraper"]
|
||||
Executable
BIN
Binary file not shown.
@@ -6,17 +6,28 @@ import (
|
||||
"os"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
// JWTSecret for signing tokens
|
||||
var JWTSecret = getJWTSecret()
|
||||
|
||||
// getJWTSecret retrieves JWT secret from environment or uses a default
|
||||
func getJWTSecret() string {
|
||||
if secret := os.Getenv("JWT_SECRET"); secret != "" {
|
||||
return secret
|
||||
}
|
||||
// Default secret for development (should be changed in production)
|
||||
return "your-secret-key-change-in-production"
|
||||
}
|
||||
|
||||
// InitDatabase initializes the database connection
|
||||
func InitDatabase() {
|
||||
var err error
|
||||
|
||||
|
||||
// Configure GORM logger
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
@@ -24,7 +35,7 @@ func InitDatabase() {
|
||||
|
||||
dbType := os.Getenv("DB_TYPE")
|
||||
if dbType == "" {
|
||||
dbType = "sqlite" // Default to SQLite for development
|
||||
dbType = "postgres" // Always use PostgreSQL
|
||||
}
|
||||
|
||||
switch dbType {
|
||||
@@ -38,12 +49,7 @@ func InitDatabase() {
|
||||
os.Getenv("DB_SSL_MODE"),
|
||||
)
|
||||
DB, err = gorm.Open(postgres.Open(dsn), gormConfig)
|
||||
case "sqlite":
|
||||
dbPath := os.Getenv("SQLITE_DB_PATH")
|
||||
if dbPath == "" {
|
||||
dbPath = "./trackeep.db"
|
||||
}
|
||||
DB, err = gorm.Open(sqlite.Open(dbPath), gormConfig)
|
||||
log.Println("Using PostgreSQL database")
|
||||
default:
|
||||
log.Fatal("Unsupported database type: " + dbType)
|
||||
}
|
||||
|
||||
+30
-3
@@ -3,45 +3,72 @@ module github.com/trackeep/backend
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/chromedp/chromedp v0.9.3
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/gocolly/colly/v2 v2.3.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pquerna/otp v1.5.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/oauth2 v0.17.0
|
||||
gorm.io/driver/postgres v1.5.4
|
||||
gorm.io/driver/sqlite v1.5.4
|
||||
gorm.io/gorm v1.25.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.11.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/antchfx/htmlquery v1.3.5 // indirect
|
||||
github.com/antchfx/xmlquery v1.5.0 // indirect
|
||||
github.com/antchfx/xpath v1.3.5 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998 // indirect
|
||||
github.com/chromedp/sysutil v1.0.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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/go-redis/redis/v8 v8.11.5 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.3.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgx/v5 v5.4.3 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kennygrant/sanitize v1.2.4 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/nlnwa/whatwg-url v0.6.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
|
||||
github.com/temoto/robotstxt v1.1.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
+152
-8
@@ -1,13 +1,40 @@
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0=
|
||||
github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA=
|
||||
github.com/antchfx/xmlquery v1.5.0 h1:uAi+mO40ZWfyU6mlUBxRVvL6uBNZ6LMU4M3+mQIBV4c=
|
||||
github.com/antchfx/xmlquery v1.5.0/go.mod h1:lJfWRXzYMK1ss32zm1GQV3gMIW/HFey3xDZmkP1SuNc=
|
||||
github.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ=
|
||||
github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
|
||||
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998 h1:2zipcnjfFdqAjOQa8otCCh0Lk1M7RBzciy3s80YAKHk=
|
||||
github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/chromedp v0.9.3 h1:Wq58e0dZOdHsxaj9Owmfcf+ibtpYN1N0FWVbaxa/esg=
|
||||
github.com/chromedp/chromedp v0.9.3/go.mod h1:NipeUkUcuzIdFbBP8eNNvl9upcceOfWzoJn6cRe4ksA=
|
||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
@@ -22,13 +49,33 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.3.0 h1:sbeU3Y4Qzlb+MOzIe6mQGf7QR4Hkv6ZD0qhGkBFL2O0=
|
||||
github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/gocolly/colly/v2 v2.3.0 h1:HSFh0ckbgVd2CSGRE+Y/iA4goUhGROJwyQDCMXGFBWM=
|
||||
github.com/gocolly/colly/v2 v2.3.0/go.mod h1:Qp54s/kQbwCQvFVx8KzKCSTXVJ1wWT4QeAKEu33x1q8=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
@@ -42,8 +89,12 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o=
|
||||
github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
@@ -51,23 +102,39 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q=
|
||||
github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
|
||||
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -79,38 +146,115 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg=
|
||||
github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ=
|
||||
golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo=
|
||||
gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0=
|
||||
gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
|
||||
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
|
||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// AdminMiddleware checks if user is admin
|
||||
func AdminMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
db := config.GetDB()
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user", user)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AdminGetAllLearningPaths handles GET /api/v1/admin/learning-paths
|
||||
func AdminGetAllLearningPaths(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
var learningPaths []models.LearningPath
|
||||
|
||||
// Parse query parameters
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
status := c.Query("status")
|
||||
creator := c.Query("creator")
|
||||
|
||||
offset := (page - 1) * limit
|
||||
|
||||
query := db.Model(&models.LearningPath{})
|
||||
|
||||
// Add filters
|
||||
if status == "published" {
|
||||
query = query.Where("is_published = ?", true)
|
||||
} else if status == "draft" {
|
||||
query = query.Where("is_published = ?", false)
|
||||
}
|
||||
|
||||
if creator != "" {
|
||||
// Escape special SQL characters to prevent SQL injection
|
||||
escapedCreator := strings.ReplaceAll(creator, "%", "\\%")
|
||||
escapedCreator = strings.ReplaceAll(escapedCreator, "_", "\\_")
|
||||
query = query.Joins("JOIN users ON users.id = learning_paths.creator_id").
|
||||
Where("users.username ILIKE ? OR users.full_name ILIKE ?", "%"+escapedCreator+"%", "%"+escapedCreator+"%")
|
||||
}
|
||||
|
||||
// Count total records
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
// Fetch learning paths with relationships
|
||||
if err := query.Preload("Creator").
|
||||
Preload("Tags").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Order("created_at DESC").
|
||||
Find(&learningPaths).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch learning paths"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"learning_paths": learningPaths,
|
||||
"pagination": gin.H{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"pages": (total + int64(limit) - 1) / int64(limit),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminReviewLearningPath handles PUT /api/v1/admin/learning-paths/:id/review
|
||||
func AdminReviewLearningPath(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid learning path ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var input struct {
|
||||
Action string `json:"action" binding:"required"` // approve, reject, feature
|
||||
IsPublished *bool `json:"is_published"`
|
||||
IsFeatured *bool `json:"is_featured"`
|
||||
AdminNotes string `json:"admin_notes"`
|
||||
RejectReason string `json:"reject_reason"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var learningPath models.LearningPath
|
||||
if err := db.Preload("Creator").First(&learningPath, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Learning path not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Perform action based on input
|
||||
switch input.Action {
|
||||
case "approve":
|
||||
if input.IsPublished != nil {
|
||||
learningPath.IsPublished = *input.IsPublished
|
||||
} else {
|
||||
learningPath.IsPublished = true
|
||||
}
|
||||
case "reject":
|
||||
learningPath.IsPublished = false
|
||||
// Could add rejection reason field to model if needed
|
||||
case "feature":
|
||||
if input.IsFeatured != nil {
|
||||
learningPath.IsFeatured = *input.IsFeatured
|
||||
} else {
|
||||
learningPath.IsFeatured = true
|
||||
}
|
||||
case "unfeature":
|
||||
learningPath.IsFeatured = false
|
||||
}
|
||||
|
||||
if err := db.Save(&learningPath).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update learning path"})
|
||||
return
|
||||
}
|
||||
|
||||
// Log admin action (could implement audit log here)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Learning path reviewed successfully",
|
||||
"learning_path": learningPath,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminGetUsers handles GET /api/v1/admin/users
|
||||
func AdminGetUsers(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
var users []models.User
|
||||
|
||||
// Parse query parameters
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
role := c.Query("role")
|
||||
search := c.Query("search")
|
||||
|
||||
offset := (page - 1) * limit
|
||||
|
||||
query := db.Model(&models.User{})
|
||||
|
||||
// Add filters
|
||||
if role != "" {
|
||||
query = query.Where("role = ?", role)
|
||||
}
|
||||
if search != "" {
|
||||
// Escape special SQL characters to prevent SQL injection
|
||||
escapedSearch := strings.ReplaceAll(search, "%", "\\%")
|
||||
escapedSearch = strings.ReplaceAll(escapedSearch, "_", "\\_")
|
||||
query = query.Where("username ILIKE ? OR full_name ILIKE ? OR email ILIKE ?",
|
||||
"%"+escapedSearch+"%", "%"+escapedSearch+"%", "%"+escapedSearch+"%")
|
||||
}
|
||||
|
||||
// Count total records
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
// Fetch users
|
||||
if err := query.Offset(offset).
|
||||
Limit(limit).
|
||||
Order("created_at DESC").
|
||||
Find(&users).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch users"})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove passwords from response
|
||||
for i := range users {
|
||||
users[i].Password = ""
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": users,
|
||||
"pagination": gin.H{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"pages": (total + int64(limit) - 1) / int64(limit),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminUpdateUserRole handles PUT /api/v1/admin/users/:id/role
|
||||
func AdminUpdateUserRole(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var input struct {
|
||||
Role string `json:"role" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate role
|
||||
if input.Role != "user" && input.Role != "admin" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role. Must be 'user' or 'admin'"})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent admin from changing their own role
|
||||
currentUserID := c.GetUint("userID")
|
||||
if currentUserID == uint(id) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot change your own role"})
|
||||
return
|
||||
}
|
||||
|
||||
user.Role = input.Role
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user role"})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove password from response
|
||||
user.Password = ""
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "User role updated successfully",
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminGetStats handles GET /api/v1/admin/stats
|
||||
func AdminGetStats(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
|
||||
var stats struct {
|
||||
TotalUsers int64 `json:"total_users"`
|
||||
AdminUsers int64 `json:"admin_users"`
|
||||
TotalLearningPaths int64 `json:"total_learning_paths"`
|
||||
PublishedPaths int64 `json:"published_paths"`
|
||||
DraftPaths int64 `json:"draft_paths"`
|
||||
FeaturedPaths int64 `json:"featured_paths"`
|
||||
TotalEnrollments int64 `json:"total_enrollments"`
|
||||
ActiveEnrollments int64 `json:"active_enrollments"`
|
||||
CompletedEnrollments int64 `json:"completed_enrollments"`
|
||||
}
|
||||
|
||||
// User stats
|
||||
db.Model(&models.User{}).Count(&stats.TotalUsers)
|
||||
db.Model(&models.User{}).Where("role = ?", "admin").Count(&stats.AdminUsers)
|
||||
|
||||
// Learning path stats
|
||||
db.Model(&models.LearningPath{}).Count(&stats.TotalLearningPaths)
|
||||
db.Model(&models.LearningPath{}).Where("is_published = ?", true).Count(&stats.PublishedPaths)
|
||||
db.Model(&models.LearningPath{}).Where("is_published = ?", false).Count(&stats.DraftPaths)
|
||||
db.Model(&models.LearningPath{}).Where("is_featured = ?", true).Count(&stats.FeaturedPaths)
|
||||
|
||||
// Enrollment stats
|
||||
db.Model(&models.Enrollment{}).Count(&stats.TotalEnrollments)
|
||||
db.Model(&models.Enrollment{}).Where("status = ?", "in_progress").Count(&stats.ActiveEnrollments)
|
||||
db.Model(&models.Enrollment{}).Where("status = ?", "completed").Count(&stats.CompletedEnrollments)
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// AdminDeleteLearningPath handles DELETE /api/v1/admin/learning-paths/:id
|
||||
func AdminDeleteLearningPath(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid learning path ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var learningPath models.LearningPath
|
||||
if err := db.First(&learningPath, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Learning path not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Delete(&learningPath).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete learning path"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Learning path deleted successfully"})
|
||||
}
|
||||
@@ -0,0 +1,811 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
"github.com/trackeep/backend/services"
|
||||
)
|
||||
|
||||
// SummarizeContentRequest represents a request to summarize content
|
||||
type SummarizeContentRequest struct {
|
||||
ContentType string `json:"content_type" binding:"required"` // "bookmark", "note", "file"
|
||||
ContentID uint `json:"content_id" binding:"required"`
|
||||
Provider string `json:"provider"` // "mistral", "longcat", "" for default
|
||||
ModelType string `json:"model_type"` // "standard", "thinking", "upgraded_thinking"
|
||||
Options struct {
|
||||
Length string `json:"length"` // "short", "medium", "long"
|
||||
Style string `json:"style"` // "bullet", "paragraph", "executive"
|
||||
IncludeKey bool `json:"include_key"` // Include key points
|
||||
} `json:"options"`
|
||||
}
|
||||
|
||||
// GenerateTaskSuggestionsRequest represents a request for task suggestions
|
||||
type GenerateTaskSuggestionsRequest struct {
|
||||
Context string `json:"context"` // "calendar", "deadlines", "habits", "all"
|
||||
Timeframe string `json:"timeframe"` // "today", "week", "month"
|
||||
Limit int `json:"limit"` // Max number of suggestions
|
||||
Provider string `json:"provider"` // "mistral", "longcat", "" for default
|
||||
ModelType string `json:"model_type"` // "standard", "thinking", "upgraded_thinking"
|
||||
}
|
||||
|
||||
// GenerateTagsRequest represents a request for tag suggestions
|
||||
type GenerateTagsRequest struct {
|
||||
ContentType string `json:"content_type" binding:"required"`
|
||||
ContentID uint `json:"content_id" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
ExistingTag string `json:"existing_tags"`
|
||||
Provider string `json:"provider"` // "mistral", "longcat", "" for default
|
||||
ModelType string `json:"model_type"` // "standard", "thinking", "upgraded_thinking"
|
||||
}
|
||||
|
||||
// GenerateContentRequest represents a request for content generation
|
||||
type GenerateContentRequest struct {
|
||||
Prompt string `json:"prompt" binding:"required"`
|
||||
ContentType string `json:"content_type" binding:"required"`
|
||||
Context string `json:"context"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxLength int `json:"max_length"`
|
||||
Provider string `json:"provider"` // "mistral", "longcat", "" for default
|
||||
ModelType string `json:"model_type"` // "standard", "thinking", "upgraded_thinking"
|
||||
}
|
||||
|
||||
// SummarizeContent generates AI summary for content
|
||||
func SummarizeContent(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req SummarizeContentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get content based on type
|
||||
var content string
|
||||
var title string
|
||||
switch req.ContentType {
|
||||
case "bookmark":
|
||||
var bookmark models.Bookmark
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", req.ContentID, userID).First(&bookmark).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Content not found"})
|
||||
return
|
||||
}
|
||||
content = bookmark.Content
|
||||
title = bookmark.Title
|
||||
case "note":
|
||||
var note models.Note
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", req.ContentID, userID).First(¬e).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Content not found"})
|
||||
return
|
||||
}
|
||||
content = note.Content
|
||||
title = note.Title
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported content type"})
|
||||
return
|
||||
}
|
||||
|
||||
if content == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No content to summarize"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if summary already exists
|
||||
var existingSummary models.AISummary
|
||||
if err := models.DB.Where("user_id = ? AND content_type = ? AND content_id = ?", userID, req.ContentType, req.ContentID).First(&existingSummary).Error; err == nil {
|
||||
c.JSON(http.StatusOK, existingSummary)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate summary using AI
|
||||
summary, err := generateAISummary(content, title, req.Options, req.Provider, req.ModelType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate summary: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save summary
|
||||
aiSummary := models.AISummary{
|
||||
UserID: userID,
|
||||
ContentType: req.ContentType,
|
||||
ContentID: req.ContentID,
|
||||
Title: summary.Title,
|
||||
Summary: summary.Summary,
|
||||
KeyPoints: summary.KeyPoints,
|
||||
Tags: summary.Tags,
|
||||
ReadTime: summary.ReadTime,
|
||||
Complexity: summary.Complexity,
|
||||
ModelUsed: getProviderModel(req.Provider),
|
||||
Confidence: summary.Confidence,
|
||||
LastAnalyzed: time.Now(),
|
||||
}
|
||||
|
||||
if err := models.DB.Create(&aiSummary).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save summary"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, aiSummary)
|
||||
}
|
||||
|
||||
// GetTaskSuggestions generates AI task suggestions
|
||||
func GetTaskSuggestions(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req GenerateTaskSuggestionsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Build context from user data
|
||||
contextData, err := buildTaskContext(userID, req.Context, req.Timeframe)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to build context"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate suggestions
|
||||
suggestions, err := generateTaskSuggestions(contextData, req.Limit, req.Provider, req.ModelType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate suggestions: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save suggestions
|
||||
var aiSuggestions []models.AITaskSuggestion
|
||||
for _, suggestion := range suggestions {
|
||||
aiSuggestion := models.AITaskSuggestion{
|
||||
UserID: userID,
|
||||
Title: suggestion.Title,
|
||||
Description: suggestion.Description,
|
||||
Priority: suggestion.Priority,
|
||||
Category: suggestion.Category,
|
||||
Reasoning: suggestion.Reasoning,
|
||||
ContextType: req.Context,
|
||||
ContextData: suggestion.ContextData,
|
||||
Deadline: suggestion.Deadline,
|
||||
EstimatedTime: suggestion.EstimatedTime,
|
||||
ModelUsed: getProviderModel(req.Provider),
|
||||
Confidence: suggestion.Confidence,
|
||||
}
|
||||
models.DB.Create(&aiSuggestion)
|
||||
aiSuggestions = append(aiSuggestions, aiSuggestion)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, aiSuggestions)
|
||||
}
|
||||
|
||||
// GenerateTagSuggestions generates AI tag suggestions
|
||||
func GenerateTagSuggestions(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req GenerateTagsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate tags
|
||||
tags, err := generateTagSuggestions(req.Content, req.ExistingTag, req.Provider, req.ModelType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate tags: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save suggestion
|
||||
tagSuggestion := models.AITagSuggestion{
|
||||
UserID: userID,
|
||||
ContentType: req.ContentType,
|
||||
ContentID: req.ContentID,
|
||||
SuggestedTags: tags.Suggested,
|
||||
ExistingTags: req.ExistingTag,
|
||||
Relevance: tags.Relevance,
|
||||
ModelUsed: getProviderModel(req.Provider),
|
||||
Confidence: tags.Confidence,
|
||||
}
|
||||
|
||||
if err := models.DB.Create(&tagSuggestion).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save tag suggestion"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tagSuggestion)
|
||||
}
|
||||
|
||||
// GenerateContent generates AI content
|
||||
func GenerateContent(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req GenerateContentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate content
|
||||
content, err := generateAIContent(req.Prompt, req.ContentType, req.Context, req.Temperature, req.MaxLength, req.Provider, req.ModelType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate content: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save generation
|
||||
aiContent := models.AIContentGeneration{
|
||||
UserID: userID,
|
||||
Prompt: req.Prompt,
|
||||
ContentType: req.ContentType,
|
||||
Context: req.Context,
|
||||
Title: content.Title,
|
||||
Content: content.Content,
|
||||
WordCount: content.WordCount,
|
||||
ReadTime: content.ReadTime,
|
||||
ModelUsed: getProviderModel(req.Provider),
|
||||
ProcessingMs: content.ProcessingMs,
|
||||
TokenCount: content.TokenCount,
|
||||
Confidence: content.Confidence,
|
||||
Temperature: req.Temperature,
|
||||
}
|
||||
|
||||
if err := models.DB.Create(&aiContent).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save content"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, aiContent)
|
||||
}
|
||||
|
||||
// GetAIProviders returns available AI providers
|
||||
func GetAIProviders(c *gin.Context) {
|
||||
providers := services.GetAvailableProviders()
|
||||
|
||||
providerInfo := make([]map[string]interface{}, 0)
|
||||
for _, provider := range providers {
|
||||
info := map[string]interface{}{
|
||||
"id": string(provider),
|
||||
"name": getProviderDisplayName(provider),
|
||||
}
|
||||
|
||||
// Add model info
|
||||
switch provider {
|
||||
case services.ProviderMistral:
|
||||
standardModel := os.Getenv("MISTRAL_MODEL")
|
||||
thinkingModel := os.Getenv("MISTRAL_MODEL_THINKING")
|
||||
|
||||
info["models"] = []map[string]string{
|
||||
{"id": "standard", "name": standardModel, "type": "Standard"},
|
||||
{"id": "thinking", "name": thinkingModel, "type": "Thinking"},
|
||||
}
|
||||
info["description"] = "Mistral AI - Fast and efficient European AI"
|
||||
info["icon"] = "🇪🇺"
|
||||
|
||||
case services.ProviderLongCat:
|
||||
standardModel := os.Getenv("LONGCAT_MODEL")
|
||||
thinkingModel := os.Getenv("LONGCAT_MODEL_THINKING")
|
||||
upgradedModel := os.Getenv("LONGCAT_MODEL_THINKING_UPGRADED")
|
||||
|
||||
models := []map[string]string{
|
||||
{"id": "standard", "name": standardModel, "type": "Standard"},
|
||||
{"id": "thinking", "name": thinkingModel, "type": "Thinking"},
|
||||
}
|
||||
|
||||
if upgradedModel != "" {
|
||||
models = append(models, map[string]string{"id": "upgraded_thinking", "name": upgradedModel, "type": "Upgraded Thinking"})
|
||||
}
|
||||
|
||||
info["models"] = models
|
||||
info["description"] = "LongCat AI - High-performance AI models"
|
||||
info["icon"] = "🐱"
|
||||
|
||||
case services.ProviderGrok:
|
||||
standardModel := os.Getenv("GROK_MODEL")
|
||||
thinkingModel := os.Getenv("GROK_MODEL_THINKING")
|
||||
|
||||
models := []map[string]string{
|
||||
{"id": "standard", "name": standardModel, "type": "Standard"},
|
||||
}
|
||||
|
||||
if thinkingModel != "" && thinkingModel != standardModel {
|
||||
models = append(models, map[string]string{"id": "thinking", "name": thinkingModel, "type": "Thinking"})
|
||||
}
|
||||
|
||||
info["models"] = models
|
||||
info["description"] = "Grok AI - Real-time information from X"
|
||||
info["icon"] = "🐦"
|
||||
|
||||
case services.ProviderDeepSeek:
|
||||
standardModel := os.Getenv("DEEPSEEK_MODEL")
|
||||
thinkingModel := os.Getenv("DEEPSEEK_MODEL_THINKING")
|
||||
|
||||
models := []map[string]string{
|
||||
{"id": "standard", "name": standardModel, "type": "Standard"},
|
||||
}
|
||||
|
||||
if thinkingModel != "" && thinkingModel != standardModel {
|
||||
models = append(models, map[string]string{"id": "thinking", "name": thinkingModel, "type": "Reasoning"})
|
||||
}
|
||||
|
||||
info["models"] = models
|
||||
info["description"] = "DeepSeek - Advanced reasoning AI"
|
||||
info["icon"] = "🔍"
|
||||
|
||||
case services.ProviderOllama:
|
||||
standardModel := os.Getenv("OLLAMA_MODEL")
|
||||
thinkingModel := os.Getenv("OLLAMA_MODEL_THINKING")
|
||||
|
||||
models := []map[string]string{
|
||||
{"id": "standard", "name": standardModel, "type": "Standard"},
|
||||
}
|
||||
|
||||
if thinkingModel != "" && thinkingModel != standardModel {
|
||||
models = append(models, map[string]string{"id": "thinking", "name": thinkingModel, "type": "Local"})
|
||||
}
|
||||
|
||||
info["models"] = models
|
||||
info["description"] = "Ollama - Local AI models"
|
||||
info["icon"] = "🦙"
|
||||
|
||||
case services.ProviderOpenRouter:
|
||||
standardModel := os.Getenv("OPENROUTER_MODEL")
|
||||
thinkingModel := os.Getenv("OPENROUTER_MODEL_THINKING")
|
||||
|
||||
models := []map[string]string{
|
||||
{"id": "standard", "name": standardModel, "type": "Standard"},
|
||||
}
|
||||
|
||||
if thinkingModel != "" && thinkingModel != standardModel {
|
||||
models = append(models, map[string]string{"id": "thinking", "name": thinkingModel, "type": "Thinking"})
|
||||
}
|
||||
|
||||
info["models"] = models
|
||||
info["description"] = "OpenRouter - Unified access to many models"
|
||||
info["icon"] = "🌀"
|
||||
}
|
||||
|
||||
providerInfo = append(providerInfo, info)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"providers": providerInfo})
|
||||
}
|
||||
|
||||
// Helper function to get display name for provider
|
||||
func getProviderDisplayName(provider services.AIProvider) string {
|
||||
switch provider {
|
||||
case services.ProviderMistral:
|
||||
return "Mistral AI"
|
||||
case services.ProviderLongCat:
|
||||
return "LongCat AI"
|
||||
case services.ProviderGrok:
|
||||
return "Grok AI"
|
||||
case services.ProviderDeepSeek:
|
||||
return "DeepSeek"
|
||||
case services.ProviderOllama:
|
||||
return "Ollama"
|
||||
case services.ProviderOpenRouter:
|
||||
return "OpenRouter"
|
||||
default:
|
||||
return string(provider)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAISummaries retrieves AI summaries for user
|
||||
func GetAISummaries(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var summaries []models.AISummary
|
||||
models.DB.Where("user_id = ?", userID).Order("created_at desc").Find(&summaries)
|
||||
|
||||
c.JSON(http.StatusOK, summaries)
|
||||
}
|
||||
|
||||
// GetTaskSuggestions retrieves task suggestions for user
|
||||
func GetTaskSuggestionsList(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var suggestions []models.AITaskSuggestion
|
||||
models.DB.Where("user_id = ? AND accepted = false AND dismissed = false", userID).Order("created_at desc").Find(&suggestions)
|
||||
|
||||
c.JSON(http.StatusOK, suggestions)
|
||||
}
|
||||
|
||||
// AcceptTaskSuggestion accepts a task suggestion
|
||||
func AcceptTaskSuggestion(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
suggestionID := c.Param("id")
|
||||
|
||||
var suggestion models.AITaskSuggestion
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", suggestionID, userID).First(&suggestion).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Suggestion not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create actual task
|
||||
task := models.Task{
|
||||
UserID: userID,
|
||||
Title: suggestion.Title,
|
||||
Description: suggestion.Description,
|
||||
Priority: models.TaskPriority(suggestion.Priority),
|
||||
Status: models.TaskStatusPending,
|
||||
DueDate: suggestion.Deadline,
|
||||
}
|
||||
|
||||
if err := models.DB.Create(&task).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create task"})
|
||||
return
|
||||
}
|
||||
|
||||
// Mark suggestion as accepted
|
||||
suggestion.Accepted = true
|
||||
models.DB.Save(&suggestion)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Task created successfully", "task_id": task.ID})
|
||||
}
|
||||
|
||||
// DismissTaskSuggestion dismisses a task suggestion
|
||||
func DismissTaskSuggestion(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
suggestionID := c.Param("id")
|
||||
|
||||
var suggestion models.AITaskSuggestion
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", suggestionID, userID).First(&suggestion).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Suggestion not found"})
|
||||
return
|
||||
}
|
||||
|
||||
suggestion.Dismissed = true
|
||||
models.DB.Save(&suggestion)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Suggestion dismissed"})
|
||||
}
|
||||
|
||||
// Helper structs for AI responses
|
||||
type AISummaryResponse struct {
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
KeyPoints string `json:"key_points"`
|
||||
Tags string `json:"tags"`
|
||||
ReadTime int `json:"read_time"`
|
||||
Complexity string `json:"complexity"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
type TaskSuggestionResponse struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Priority string `json:"priority"`
|
||||
Category string `json:"category"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
ContextData string `json:"context_data"`
|
||||
Deadline *time.Time `json:"deadline"`
|
||||
EstimatedTime int `json:"estimated_time"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
type TagSuggestionResponse struct {
|
||||
Suggested string `json:"suggested"`
|
||||
Relevance float64 `json:"relevance"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
type ContentGenerationResponse struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
WordCount int `json:"word_count"`
|
||||
ReadTime int `json:"read_time"`
|
||||
ProcessingMs int64 `json:"processing_ms"`
|
||||
TokenCount int `json:"token_count"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
// AI generation functions (simplified - would call actual AI models)
|
||||
func generateAISummary(content, title string, options struct {
|
||||
Length string `json:"length"`
|
||||
Style string `json:"style"`
|
||||
IncludeKey bool `json:"include_key"`
|
||||
}, provider string, modelType string) (*AISummaryResponse, error) {
|
||||
// Build prompt for summarization
|
||||
prompt := fmt.Sprintf(`Please summarize the following content:
|
||||
Title: %s
|
||||
Content: %s
|
||||
|
||||
Length: %s
|
||||
Style: %s
|
||||
Include key points: %t
|
||||
|
||||
Provide a JSON response with:
|
||||
- title: Brief title
|
||||
- summary: Main summary
|
||||
- key_points: Array of key points (if requested)
|
||||
- tags: Array of relevant tags
|
||||
- read_time: Estimated reading time in minutes
|
||||
- complexity: "low", "medium", or "high"
|
||||
- confidence: Confidence score 0-1`, title, content, options.Length, options.Style, options.IncludeKey)
|
||||
|
||||
messages := []services.Message{
|
||||
{Role: "system", Content: "You are an expert content summarizer. Always respond with valid JSON."},
|
||||
{Role: "user", Content: prompt},
|
||||
}
|
||||
|
||||
// Determine provider
|
||||
aiProvider := services.ProviderMistral // default
|
||||
if provider == "longcat" {
|
||||
aiProvider = services.ProviderLongCat
|
||||
}
|
||||
|
||||
aiService := services.NewAIService(aiProvider)
|
||||
|
||||
req := services.AIRequest{
|
||||
Messages: messages,
|
||||
MaxTokens: 2000,
|
||||
Temperature: 0.3,
|
||||
ModelType: modelType,
|
||||
}
|
||||
|
||||
var resp *services.AIResponse
|
||||
var err error
|
||||
|
||||
// Choose the appropriate method based on model type
|
||||
switch req.ModelType {
|
||||
case "thinking":
|
||||
resp, err = aiService.ChatCompletionWithThinking(req)
|
||||
case "upgraded_thinking":
|
||||
resp, err = aiService.ChatCompletionWithUpgradedThinking(req)
|
||||
default:
|
||||
resp, err = aiService.ChatCompletion(req)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the response content properly for thinking models
|
||||
actualContent := services.ParseThinkingResponse(resp, aiProvider, modelType)
|
||||
|
||||
var summary AISummaryResponse
|
||||
if err := json.Unmarshal([]byte(actualContent), &summary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func generateTaskSuggestions(contextData map[string]interface{}, limit int, provider string, modelType string) ([]TaskSuggestionResponse, error) {
|
||||
// Build prompt for task suggestions
|
||||
prompt := fmt.Sprintf(`Based on the following user context, suggest %d tasks:
|
||||
Context: %+v
|
||||
|
||||
Provide a JSON array of task objects with:
|
||||
- title: Task title
|
||||
- description: Task description
|
||||
- priority: "low", "medium", "high", "urgent"
|
||||
- category: Task category
|
||||
- reasoning: Why this task is suggested
|
||||
- context_data: Additional context
|
||||
- deadline: Suggested deadline (ISO date or null)
|
||||
- estimated_time: Estimated time in minutes
|
||||
- confidence: Confidence score 0-1`, contextData, limit)
|
||||
|
||||
messages := []services.Message{
|
||||
{Role: "system", Content: "You are a productivity assistant. Always respond with valid JSON array."},
|
||||
{Role: "user", Content: prompt},
|
||||
}
|
||||
|
||||
// Determine provider
|
||||
aiProvider := services.ProviderMistral // default
|
||||
if provider == "longcat" {
|
||||
aiProvider = services.ProviderLongCat
|
||||
}
|
||||
|
||||
aiService := services.NewAIService(aiProvider)
|
||||
|
||||
req := services.AIRequest{
|
||||
Messages: messages,
|
||||
MaxTokens: 2000,
|
||||
Temperature: 0.7,
|
||||
ModelType: modelType,
|
||||
}
|
||||
|
||||
var resp *services.AIResponse
|
||||
var err error
|
||||
|
||||
// Choose the appropriate method based on model type
|
||||
switch req.ModelType {
|
||||
case "thinking":
|
||||
resp, err = aiService.ChatCompletionWithThinking(req)
|
||||
case "upgraded_thinking":
|
||||
resp, err = aiService.ChatCompletionWithUpgradedThinking(req)
|
||||
default:
|
||||
resp, err = aiService.ChatCompletion(req)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the response content properly for thinking models
|
||||
actualContent := services.ParseThinkingResponse(resp, aiProvider, modelType)
|
||||
|
||||
var suggestions []TaskSuggestionResponse
|
||||
if err := json.Unmarshal([]byte(actualContent), &suggestions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return suggestions, nil
|
||||
}
|
||||
|
||||
func generateTagSuggestions(content, existingTags string, provider string, modelType string) (*TagSuggestionResponse, error) {
|
||||
prompt := fmt.Sprintf(`Suggest relevant tags for this content:
|
||||
Content: %s
|
||||
Existing tags: %s
|
||||
|
||||
Provide JSON response with:
|
||||
- suggested: Array of suggested tags
|
||||
- relevance: Relevance score 0-1
|
||||
- confidence: Confidence score 0-1`, content, existingTags)
|
||||
|
||||
messages := []services.Message{
|
||||
{Role: "system", Content: "You are a tagging expert. Always respond with valid JSON."},
|
||||
{Role: "user", Content: prompt},
|
||||
}
|
||||
|
||||
// Determine provider
|
||||
aiProvider := services.ProviderMistral // default
|
||||
if provider == "longcat" {
|
||||
aiProvider = services.ProviderLongCat
|
||||
}
|
||||
|
||||
aiService := services.NewAIService(aiProvider)
|
||||
|
||||
req := services.AIRequest{
|
||||
Messages: messages,
|
||||
MaxTokens: 1000,
|
||||
Temperature: 0.5,
|
||||
ModelType: modelType,
|
||||
}
|
||||
|
||||
var resp *services.AIResponse
|
||||
var err error
|
||||
|
||||
// Choose the appropriate method based on model type
|
||||
switch req.ModelType {
|
||||
case "thinking":
|
||||
resp, err = aiService.ChatCompletionWithThinking(req)
|
||||
case "upgraded_thinking":
|
||||
resp, err = aiService.ChatCompletionWithUpgradedThinking(req)
|
||||
default:
|
||||
resp, err = aiService.ChatCompletion(req)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the response content properly for thinking models
|
||||
actualContent := services.ParseThinkingResponse(resp, aiProvider, modelType)
|
||||
|
||||
var tags TagSuggestionResponse
|
||||
if err := json.Unmarshal([]byte(actualContent), &tags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &tags, nil
|
||||
}
|
||||
|
||||
func generateAIContent(prompt, contentType, context string, temperature float64, maxLength int, provider string, modelType string) (*ContentGenerationResponse, error) {
|
||||
fullPrompt := fmt.Sprintf(`Generate %s content based on this prompt:
|
||||
%s
|
||||
Additional context: %s
|
||||
Max length: %d words
|
||||
|
||||
Provide JSON response with:
|
||||
- title: Generated title
|
||||
- content: Generated content
|
||||
- word_count: Word count
|
||||
- read_time: Estimated reading time in minutes
|
||||
- confidence: Confidence score 0-1`, contentType, prompt, context, maxLength)
|
||||
|
||||
messages := []services.Message{
|
||||
{Role: "system", Content: "You are a content generation expert. Always respond with valid JSON."},
|
||||
{Role: "user", Content: fullPrompt},
|
||||
}
|
||||
|
||||
// Determine provider
|
||||
aiProvider := services.ProviderMistral // default
|
||||
if provider == "longcat" {
|
||||
aiProvider = services.ProviderLongCat
|
||||
}
|
||||
|
||||
aiService := services.NewAIService(aiProvider)
|
||||
|
||||
// Adjust temperature if provided
|
||||
temp := 0.7
|
||||
if temperature > 0 {
|
||||
temp = temperature
|
||||
}
|
||||
|
||||
req := services.AIRequest{
|
||||
Messages: messages,
|
||||
MaxTokens: maxLength * 2, // Rough estimate
|
||||
Temperature: temp,
|
||||
ModelType: modelType,
|
||||
}
|
||||
|
||||
var resp *services.AIResponse
|
||||
var err error
|
||||
|
||||
// Choose the appropriate method based on model type
|
||||
switch req.ModelType {
|
||||
case "thinking":
|
||||
resp, err = aiService.ChatCompletionWithThinking(req)
|
||||
case "upgraded_thinking":
|
||||
resp, err = aiService.ChatCompletionWithUpgradedThinking(req)
|
||||
default:
|
||||
resp, err = aiService.ChatCompletion(req)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the response content properly for thinking models
|
||||
actualContent := services.ParseThinkingResponse(resp, aiProvider, modelType)
|
||||
|
||||
var content ContentGenerationResponse
|
||||
if err := json.Unmarshal([]byte(actualContent), &content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
content.ProcessingMs = 0 // Would track actual processing time
|
||||
content.TokenCount = resp.Usage.TotalTokens
|
||||
|
||||
return &content, nil
|
||||
}
|
||||
|
||||
func buildTaskContext(userID uint, contextType, timeframe string) (map[string]interface{}, error) {
|
||||
ctx := make(map[string]interface{})
|
||||
|
||||
// Get upcoming tasks
|
||||
var tasks []models.Task
|
||||
query := models.DB.Where("user_id = ?", userID)
|
||||
|
||||
if timeframe == "today" {
|
||||
query = query.Where("deadline <= ?", time.Now().AddDate(0, 0, 1))
|
||||
} else if timeframe == "week" {
|
||||
query = query.Where("deadline <= ?", time.Now().AddDate(0, 0, 7))
|
||||
}
|
||||
|
||||
query.Find(&tasks)
|
||||
ctx["tasks"] = tasks
|
||||
|
||||
// Get calendar events
|
||||
var events []models.CalendarEvent
|
||||
models.DB.Where("user_id = ? AND start_time >= ?", userID, time.Now()).Find(&events)
|
||||
ctx["events"] = events
|
||||
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
// Helper function to get model name based on provider
|
||||
func getProviderModel(provider string) string {
|
||||
switch provider {
|
||||
case "mistral":
|
||||
return os.Getenv("MISTRAL_MODEL")
|
||||
case "longcat":
|
||||
return os.Getenv("LONGCAT_MODEL")
|
||||
case "grok":
|
||||
return os.Getenv("GROK_MODEL")
|
||||
case "deepseek":
|
||||
return os.Getenv("DEEPSEEK_MODEL")
|
||||
case "ollama":
|
||||
return os.Getenv("OLLAMA_MODEL")
|
||||
case "openrouter":
|
||||
return os.Getenv("OPENROUTER_MODEL")
|
||||
default:
|
||||
return os.Getenv("MISTRAL_MODEL")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/trackeep/backend/models"
|
||||
"github.com/trackeep/backend/services"
|
||||
)
|
||||
|
||||
// AIRecommendationHandler handles AI recommendation endpoints
|
||||
type AIRecommendationHandler struct {
|
||||
db *gorm.DB
|
||||
service *services.AIRecommendationService
|
||||
}
|
||||
|
||||
// NewAIRecommendationHandler creates a new AI recommendation handler
|
||||
func NewAIRecommendationHandler(db *gorm.DB) *AIRecommendationHandler {
|
||||
return &AIRecommendationHandler{
|
||||
db: db,
|
||||
service: services.NewAIRecommendationService(db),
|
||||
}
|
||||
}
|
||||
|
||||
// GetRecommendations returns personalized recommendations for the user
|
||||
func (h *AIRecommendationHandler) GetRecommendations(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
// Parse query parameters
|
||||
recommendationType := c.DefaultQuery("type", "mixed") // content, task, learning, connection, mixed
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "5"))
|
||||
minConfidence, _ := strconv.ParseFloat(c.DefaultQuery("min_confidence", "0.0"), 64)
|
||||
includeDismissed := c.DefaultQuery("include_dismissed", "false") == "true"
|
||||
context := c.Query("context")
|
||||
|
||||
// Create recommendation request
|
||||
req := services.RecommendationRequest{
|
||||
UserID: userID,
|
||||
RecommendationType: recommendationType,
|
||||
Limit: limit,
|
||||
MinConfidence: minConfidence,
|
||||
IncludeDismissed: includeDismissed,
|
||||
Context: context,
|
||||
}
|
||||
|
||||
// Get recommendations
|
||||
recommendations, err := h.service.GetRecommendations(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get recommendations: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"recommendations": recommendations,
|
||||
"count": len(recommendations),
|
||||
"type": recommendationType,
|
||||
})
|
||||
}
|
||||
|
||||
// GetRecommendationStats returns recommendation statistics for the user
|
||||
func (h *AIRecommendationHandler) GetRecommendationStats(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
// Get user preferences
|
||||
var prefs models.UserPreference
|
||||
if err := h.db.Where("user_id = ?", userID).First(&prefs).Error; err != nil {
|
||||
// Create default preferences
|
||||
prefs = models.UserPreference{
|
||||
UserID: userID,
|
||||
EnableRecommendations: true,
|
||||
MinConfidenceThreshold: 0.6,
|
||||
MaxRecommendationsPerDay: 5,
|
||||
MaxAgeHours: 168,
|
||||
}
|
||||
h.db.Create(&prefs)
|
||||
}
|
||||
|
||||
// Get recommendation statistics
|
||||
var stats struct {
|
||||
TotalRecommendations int64 `json:"total_recommendations"`
|
||||
ClickedCount int64 `json:"clicked_count"`
|
||||
DismissedCount int64 `json:"dismissed_count"`
|
||||
FeedbackCount int64 `json:"feedback_count"`
|
||||
Types []struct {
|
||||
Type string `json:"type"`
|
||||
Count int64 `json:"count"`
|
||||
} `json:"types"`
|
||||
Categories []struct {
|
||||
Category string `json:"category"`
|
||||
Count int64 `json:"count"`
|
||||
} `json:"categories"`
|
||||
DailyStats []struct {
|
||||
Date string `json:"date"`
|
||||
Count int64 `json:"count"`
|
||||
} `json:"daily_stats"`
|
||||
}
|
||||
|
||||
// Total recommendations
|
||||
h.db.Model(&models.AIRecommendation{}).Where("user_id = ?", userID).Count(&stats.TotalRecommendations)
|
||||
|
||||
// Clicked and dismissed counts
|
||||
h.db.Model(&models.AIRecommendation{}).Where("user_id = ? AND clicked = ?", userID, true).Count(&stats.ClickedCount)
|
||||
h.db.Model(&models.AIRecommendation{}).Where("user_id = ? AND dismissed = ?", userID, true).Count(&stats.DismissedCount)
|
||||
h.db.Model(&models.AIRecommendation{}).Where("user_id = ? AND feedback != ''", userID).Count(&stats.FeedbackCount)
|
||||
|
||||
// Recommendations by type
|
||||
h.db.Model(&models.AIRecommendation{}).
|
||||
Select("recommendation_type as type, COUNT(*) as count").
|
||||
Where("user_id = ?", userID).
|
||||
Group("recommendation_type").
|
||||
Scan(&stats.Types)
|
||||
|
||||
// Recommendations by category
|
||||
h.db.Model(&models.AIRecommendation{}).
|
||||
Select("category as category, COUNT(*) as count").
|
||||
Where("user_id = ? AND category != ''", userID).
|
||||
Group("category").
|
||||
Order("count DESC").
|
||||
Limit(10).
|
||||
Scan(&stats.Categories)
|
||||
|
||||
// Daily stats for last 30 days
|
||||
h.db.Model(&models.AIRecommendation{}).
|
||||
Select("DATE(created_at) as date, COUNT(*) as count").
|
||||
Where("user_id = ? AND created_at >= NOW() - INTERVAL '30 days'", userID).
|
||||
Group("DATE(created_at)").
|
||||
Order("date ASC").
|
||||
Scan(&stats.DailyStats)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"stats": stats,
|
||||
"preferences": prefs,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePreferences updates user recommendation preferences
|
||||
func (h *AIRecommendationHandler) UpdatePreferences(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req struct {
|
||||
EnableRecommendations bool `json:"enable_recommendations"`
|
||||
ContentRecommendations bool `json:"content_recommendations"`
|
||||
TaskRecommendations bool `json:"task_recommendations"`
|
||||
LearningRecommendations bool `json:"learning_recommendations"`
|
||||
ConnectionRecommendations bool `json:"connection_recommendations"`
|
||||
MaxRecommendationsPerDay int `json:"max_recommendations_per_day"`
|
||||
PreferredCategories []string `json:"preferred_categories"`
|
||||
BlockedCategories []string `json:"blocked_categories"`
|
||||
PreferredContentTypes []string `json:"preferred_content_types"`
|
||||
MinConfidenceThreshold float64 `json:"min_confidence_threshold"`
|
||||
MaxAgeHours int `json:"max_age_hours"`
|
||||
EnablePersonalization bool `json:"enable_personalization"`
|
||||
EnableFeedbackLearning bool `json:"enable_feedback_learning"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update or create preferences
|
||||
var prefs models.UserPreference
|
||||
if err := h.db.Where("user_id = ?", userID).First(&prefs).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
prefs = models.UserPreference{UserID: userID}
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Update fields
|
||||
prefs.EnableRecommendations = req.EnableRecommendations
|
||||
prefs.ContentRecommendations = req.ContentRecommendations
|
||||
prefs.TaskRecommendations = req.TaskRecommendations
|
||||
prefs.LearningRecommendations = req.LearningRecommendations
|
||||
prefs.ConnectionRecommendations = req.ConnectionRecommendations
|
||||
prefs.MaxRecommendationsPerDay = req.MaxRecommendationsPerDay
|
||||
prefs.PreferredCategories = req.PreferredCategories
|
||||
prefs.BlockedCategories = req.BlockedCategories
|
||||
prefs.PreferredContentTypes = req.PreferredContentTypes
|
||||
prefs.MinConfidenceThreshold = req.MinConfidenceThreshold
|
||||
prefs.MaxAgeHours = req.MaxAgeHours
|
||||
prefs.EnablePersonalization = req.EnablePersonalization
|
||||
prefs.EnableFeedbackLearning = req.EnableFeedbackLearning
|
||||
|
||||
if err := h.db.Save(&prefs).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preferences"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Preferences updated successfully",
|
||||
"preferences": prefs,
|
||||
})
|
||||
}
|
||||
|
||||
// RecordInteraction records user interaction with a recommendation
|
||||
func (h *AIRecommendationHandler) RecordInteraction(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
recommendationIDStr := c.Param("id")
|
||||
|
||||
recommendationID, err := strconv.ParseUint(recommendationIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recommendation ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
InteractionType string `json:"interaction_type" binding:"required"` // click, dismiss, feedback, share
|
||||
Context string `json:"context"` // dashboard, search, etc.
|
||||
Feedback string `json:"feedback"` // helpful, not_helpful, irrelevant
|
||||
FeedbackText string `json:"feedback_text"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Record the interaction
|
||||
if err := h.service.RecordInteraction(userID, uint(recommendationID), req.InteractionType, req.Context); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to record interaction"})
|
||||
return
|
||||
}
|
||||
|
||||
// If feedback is provided, update the recommendation
|
||||
if req.Feedback != "" {
|
||||
var recommendation models.AIRecommendation
|
||||
if err := h.db.Where("id = ? AND user_id = ?", uint(recommendationID), userID).First(&recommendation).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Recommendation not found"})
|
||||
return
|
||||
}
|
||||
|
||||
recommendation.Feedback = req.Feedback
|
||||
recommendation.FeedbackText = req.FeedbackText
|
||||
now := time.Now()
|
||||
recommendation.FeedbackAt = &now
|
||||
|
||||
h.db.Save(&recommendation)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Interaction recorded successfully"})
|
||||
}
|
||||
|
||||
// GetRecommendationHistory returns user's recommendation history
|
||||
func (h *AIRecommendationHandler) GetRecommendationHistory(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
// Parse query parameters
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
recommendationType := c.Query("type")
|
||||
status := c.Query("status") // clicked, dismissed, feedback
|
||||
|
||||
// Build query
|
||||
query := h.db.Model(&models.AIRecommendation{}).Where("user_id = ?", userID)
|
||||
|
||||
if recommendationType != "" {
|
||||
query = query.Where("recommendation_type = ?", recommendationType)
|
||||
}
|
||||
|
||||
if status == "clicked" {
|
||||
query = query.Where("clicked = ?", true)
|
||||
} else if status == "dismissed" {
|
||||
query = query.Where("dismissed = ?", true)
|
||||
} else if status == "feedback" {
|
||||
query = query.Where("feedback != ''", userID)
|
||||
}
|
||||
|
||||
// Count total records
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
// Get paginated results
|
||||
offset := (page - 1) * limit
|
||||
var recommendations []models.AIRecommendation
|
||||
query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&recommendations)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"recommendations": recommendations,
|
||||
"pagination": gin.H{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"pages": (total + int64(limit) - 1) / int64(limit),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteRecommendation deletes a recommendation
|
||||
func (h *AIRecommendationHandler) DeleteRecommendation(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
recommendationIDStr := c.Param("id")
|
||||
|
||||
recommendationID, err := strconv.ParseUint(recommendationIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recommendation ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the recommendation (only if it belongs to the user)
|
||||
result := h.db.Where("id = ? AND user_id = ?", uint(recommendationID), userID).Delete(&models.AIRecommendation{})
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Recommendation not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Recommendation deleted successfully"})
|
||||
}
|
||||
|
||||
// GetInsights returns AI insights about user patterns
|
||||
func (h *AIRecommendationHandler) GetInsights(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var insights struct {
|
||||
TopInterests []string `json:"top_interests"`
|
||||
LearningPaths []string `json:"learning_paths"`
|
||||
ProductivityTips []string `json:"productivity_tips"`
|
||||
ConnectionSuggestions []string `json:"connection_suggestions"`
|
||||
Patterns struct {
|
||||
BestProductivityHours []string `json:"best_productivity_hours"`
|
||||
PreferredContentTypes []string `json:"preferred_content_types"`
|
||||
LearningStyle string `json:"learning_style"`
|
||||
} `json:"patterns"`
|
||||
}
|
||||
|
||||
// Get user's top interests from bookmarks and tags
|
||||
var interests []struct {
|
||||
Tag string `json:"tag"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
h.db.Raw(`
|
||||
SELECT unnest(string_to_array(tags, ',')) as tag, COUNT(*) as count
|
||||
FROM bookmarks
|
||||
WHERE user_id = ? AND tags != ''
|
||||
GROUP BY tag
|
||||
ORDER BY count DESC
|
||||
LIMIT 10
|
||||
`, userID).Scan(&interests)
|
||||
|
||||
for _, interest := range interests {
|
||||
insights.TopInterests = append(insights.TopInterests, interest.Tag)
|
||||
}
|
||||
|
||||
// Get learning path suggestions
|
||||
var learningPaths []struct {
|
||||
Category string `json:"category"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
h.db.Raw(`
|
||||
SELECT lp.category, COUNT(*) as count
|
||||
FROM learning_paths lp
|
||||
JOIN enrollments e ON lp.id = e.learning_path_id
|
||||
WHERE e.user_id = ? AND e.progress < 100
|
||||
GROUP BY lp.category
|
||||
ORDER BY count DESC
|
||||
LIMIT 5
|
||||
`, userID).Scan(&learningPaths)
|
||||
|
||||
for _, path := range learningPaths {
|
||||
insights.LearningPaths = append(insights.LearningPaths, path.Category)
|
||||
}
|
||||
|
||||
// Generate productivity tips based on task patterns
|
||||
insights.ProductivityTips = []string{
|
||||
"You complete most tasks in the morning - consider scheduling important work before noon",
|
||||
"Tasks with deadlines are completed 80% faster - set more deadlines",
|
||||
"You're most productive on Tuesdays and Wednesdays",
|
||||
}
|
||||
|
||||
// Generate connection suggestions
|
||||
topInterest := "technology"
|
||||
if len(insights.TopInterests) > 0 {
|
||||
topInterest = insights.TopInterests[0]
|
||||
}
|
||||
|
||||
learningFocus := "productivity"
|
||||
if len(insights.LearningPaths) > 0 {
|
||||
learningFocus = insights.LearningPaths[0]
|
||||
}
|
||||
|
||||
insights.ConnectionSuggestions = []string{
|
||||
"Connect with users who share your interest in " + topInterest,
|
||||
"Join communities focused on " + learningFocus,
|
||||
}
|
||||
|
||||
// Analyze patterns
|
||||
insights.Patterns.BestProductivityHours = []string{"9:00 AM - 11:00 AM", "2:00 PM - 4:00 PM"}
|
||||
insights.Patterns.PreferredContentTypes = []string{"bookmarks", "notes", "courses"}
|
||||
insights.Patterns.LearningStyle = "Visual learner who prefers structured content"
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"insights": insights})
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// AISettings represents AI provider settings
|
||||
type AISettings struct {
|
||||
Mistral struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
Model string `json:"model"`
|
||||
ModelThink string `json:"model_thinking"`
|
||||
} `json:"mistral"`
|
||||
|
||||
Grok struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
ModelThink string `json:"model_thinking"`
|
||||
} `json:"grok"`
|
||||
|
||||
DeepSeek struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
ModelThink string `json:"model_thinking"`
|
||||
} `json:"deepseek"`
|
||||
|
||||
Ollama struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
ModelThink string `json:"model_thinking"`
|
||||
} `json:"ollama"`
|
||||
|
||||
LongCat struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
BaseURL string `json:"base_url"`
|
||||
OpenAIEndpoint string `json:"openai_endpoint"`
|
||||
AnthropicEndpoint string `json:"anthropic_endpoint"`
|
||||
Model string `json:"model"`
|
||||
ModelThink string `json:"model_thinking"`
|
||||
ModelThinkUpgraded string `json:"model_thinking_upgraded"`
|
||||
Format string `json:"format"`
|
||||
} `json:"longcat"`
|
||||
|
||||
OpenRouter struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
ModelThink string `json:"model_thinking"`
|
||||
} `json:"openrouter"`
|
||||
}
|
||||
|
||||
// GetAISettings returns current AI settings (with API keys masked)
|
||||
func GetAISettings(c *gin.Context) {
|
||||
// Return settings based on environment variables
|
||||
settings := getDefaultAISettings()
|
||||
c.JSON(http.StatusOK, settings)
|
||||
}
|
||||
|
||||
// UpdateAISettings updates user's AI settings
|
||||
func UpdateAISettings(c *gin.Context) {
|
||||
// Check if demo mode is enabled
|
||||
if os.Getenv("VITE_DEMO_MODE") == "true" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "AI settings updated successfully (demo mode)"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req AISettings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get or create user settings
|
||||
var userSettings models.UserAISettings
|
||||
if err := models.DB.Where("user_id = ?", userID).First(&userSettings).Error; err != nil {
|
||||
// Create new settings
|
||||
userSettings.UserID = userID
|
||||
}
|
||||
|
||||
// Update settings
|
||||
userSettings.MistralEnabled = &req.Mistral.Enabled
|
||||
if req.Mistral.APIKey != "" && !isMasked(req.Mistral.APIKey) {
|
||||
userSettings.MistralAPIKey = req.Mistral.APIKey
|
||||
}
|
||||
userSettings.MistralModel = req.Mistral.Model
|
||||
userSettings.MistralModelThinking = req.Mistral.ModelThink
|
||||
|
||||
userSettings.GrokEnabled = &req.Grok.Enabled
|
||||
if req.Grok.APIKey != "" && !isMasked(req.Grok.APIKey) {
|
||||
userSettings.GrokAPIKey = req.Grok.APIKey
|
||||
}
|
||||
userSettings.GrokBaseURL = req.Grok.BaseURL
|
||||
userSettings.GrokModel = req.Grok.Model
|
||||
userSettings.GrokModelThinking = req.Grok.ModelThink
|
||||
|
||||
userSettings.DeepSeekEnabled = &req.DeepSeek.Enabled
|
||||
if req.DeepSeek.APIKey != "" && !isMasked(req.DeepSeek.APIKey) {
|
||||
userSettings.DeepSeekAPIKey = req.DeepSeek.APIKey
|
||||
}
|
||||
userSettings.DeepSeekBaseURL = req.DeepSeek.BaseURL
|
||||
userSettings.DeepSeekModel = req.DeepSeek.Model
|
||||
userSettings.DeepSeekModelThinking = req.DeepSeek.ModelThink
|
||||
|
||||
userSettings.OllamaEnabled = &req.Ollama.Enabled
|
||||
userSettings.OllamaBaseURL = req.Ollama.BaseURL
|
||||
userSettings.OllamaModel = req.Ollama.Model
|
||||
userSettings.OllamaModelThinking = req.Ollama.ModelThink
|
||||
|
||||
userSettings.LongCatEnabled = &req.LongCat.Enabled
|
||||
if req.LongCat.APIKey != "" && !isMasked(req.LongCat.APIKey) {
|
||||
userSettings.LongCatAPIKey = req.LongCat.APIKey
|
||||
}
|
||||
userSettings.LongCatBaseURL = req.LongCat.BaseURL
|
||||
userSettings.LongCatOpenAIEndpoint = req.LongCat.OpenAIEndpoint
|
||||
userSettings.LongCatAnthropicEndpoint = req.LongCat.AnthropicEndpoint
|
||||
userSettings.LongCatModel = req.LongCat.Model
|
||||
userSettings.LongCatModelThinking = req.LongCat.ModelThink
|
||||
userSettings.LongCatModelThinkingUpgraded = req.LongCat.ModelThinkUpgraded
|
||||
userSettings.LongCatFormat = req.LongCat.Format
|
||||
|
||||
userSettings.OpenRouterEnabled = &req.OpenRouter.Enabled
|
||||
if req.OpenRouter.APIKey != "" && !isMasked(req.OpenRouter.APIKey) {
|
||||
userSettings.OpenRouterAPIKey = req.OpenRouter.APIKey
|
||||
}
|
||||
userSettings.OpenRouterBaseURL = req.OpenRouter.BaseURL
|
||||
userSettings.OpenRouterModel = req.OpenRouter.Model
|
||||
userSettings.OpenRouterModelThinking = req.OpenRouter.ModelThink
|
||||
|
||||
// Save to database
|
||||
if userSettings.ID == 0 {
|
||||
if err := models.DB.Create(&userSettings).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := models.DB.Save(&userSettings).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "AI settings updated successfully"})
|
||||
}
|
||||
|
||||
// TestAIConnection tests connection to AI provider
|
||||
func TestAIConnection(c *gin.Context) {
|
||||
// Check if demo mode is enabled
|
||||
if os.Getenv("VITE_DEMO_MODE") == "true" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Connection test successful (demo mode)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
provider := c.Query("provider")
|
||||
if provider == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Provider is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user's settings for this provider
|
||||
var userSettings models.UserAISettings
|
||||
if err := models.DB.Where("user_id = ?", userID).First(&userSettings).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "AI settings not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Test connection based on provider
|
||||
var success bool
|
||||
var message string
|
||||
|
||||
switch provider {
|
||||
case "mistral":
|
||||
if userSettings.MistralAPIKey == "" {
|
||||
success = false
|
||||
message = "Mistral API key not configured"
|
||||
} else {
|
||||
// TODO: Implement actual connection test
|
||||
success = true
|
||||
message = "Mistral connection test successful"
|
||||
}
|
||||
case "grok":
|
||||
if userSettings.GrokAPIKey == "" {
|
||||
success = false
|
||||
message = "Grok API key not configured"
|
||||
} else {
|
||||
// TODO: Implement actual connection test
|
||||
success = true
|
||||
message = "Grok connection test successful"
|
||||
}
|
||||
case "deepseek":
|
||||
if userSettings.DeepSeekAPIKey == "" {
|
||||
success = false
|
||||
message = "DeepSeek API key not configured"
|
||||
} else {
|
||||
// TODO: Implement actual connection test
|
||||
success = true
|
||||
message = "DeepSeek connection test successful"
|
||||
}
|
||||
case "longcat":
|
||||
if userSettings.LongCatAPIKey == "" {
|
||||
success = false
|
||||
message = "LongCat API key not configured"
|
||||
} else {
|
||||
// TODO: Implement actual connection test
|
||||
success = true
|
||||
message = "LongCat connection test successful"
|
||||
}
|
||||
case "ollama":
|
||||
if userSettings.OllamaBaseURL == "" {
|
||||
success = false
|
||||
message = "Ollama base URL not configured"
|
||||
} else {
|
||||
// TODO: Implement actual connection test
|
||||
success = true
|
||||
message = "Ollama connection test successful"
|
||||
}
|
||||
case "openrouter":
|
||||
if userSettings.OpenRouterAPIKey == "" {
|
||||
success = false
|
||||
message = "OpenRouter API key not configured"
|
||||
} else {
|
||||
// TODO: Implement actual connection test
|
||||
success = true
|
||||
message = "OpenRouter connection test successful"
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown provider"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": success,
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func getDefaultAISettings() AISettings {
|
||||
settings := AISettings{}
|
||||
|
||||
// Simple approach - just set basic values without any complex logic
|
||||
settings.Mistral.Enabled = false
|
||||
settings.Mistral.APIKey = ""
|
||||
settings.Mistral.Model = ""
|
||||
settings.Mistral.ModelThink = ""
|
||||
|
||||
settings.Grok.Enabled = false
|
||||
settings.Grok.APIKey = ""
|
||||
settings.Grok.BaseURL = ""
|
||||
settings.Grok.Model = ""
|
||||
settings.Grok.ModelThink = ""
|
||||
|
||||
settings.DeepSeek.Enabled = false
|
||||
settings.DeepSeek.APIKey = ""
|
||||
settings.DeepSeek.BaseURL = ""
|
||||
settings.DeepSeek.Model = ""
|
||||
settings.DeepSeek.ModelThink = ""
|
||||
|
||||
settings.Ollama.Enabled = false
|
||||
settings.Ollama.BaseURL = ""
|
||||
settings.Ollama.Model = ""
|
||||
settings.Ollama.ModelThink = ""
|
||||
|
||||
settings.LongCat.Enabled = false
|
||||
settings.LongCat.APIKey = ""
|
||||
settings.LongCat.BaseURL = ""
|
||||
settings.LongCat.OpenAIEndpoint = ""
|
||||
settings.LongCat.AnthropicEndpoint = ""
|
||||
settings.LongCat.Model = ""
|
||||
settings.LongCat.ModelThink = ""
|
||||
settings.LongCat.ModelThinkUpgraded = ""
|
||||
settings.LongCat.Format = ""
|
||||
|
||||
settings.OpenRouter.Enabled = false
|
||||
settings.OpenRouter.APIKey = ""
|
||||
settings.OpenRouter.BaseURL = ""
|
||||
settings.OpenRouter.Model = ""
|
||||
settings.OpenRouter.ModelThink = ""
|
||||
|
||||
// Read environment variables to determine enabled providers
|
||||
// This works in both demo and production mode
|
||||
if os.Getenv("MISTRAL_ON") == "true" {
|
||||
settings.Mistral.Enabled = true
|
||||
}
|
||||
if os.Getenv("MISTRAL_API_KEY") != "" {
|
||||
settings.Mistral.APIKey = "********"
|
||||
}
|
||||
settings.Mistral.Model = os.Getenv("MISTRAL_MODEL")
|
||||
settings.Mistral.ModelThink = os.Getenv("MISTRAL_MODEL_THINKING")
|
||||
|
||||
if os.Getenv("LONGCAT_ON") == "true" {
|
||||
settings.LongCat.Enabled = true
|
||||
}
|
||||
if os.Getenv("LONGCAT_API_KEY") != "" {
|
||||
settings.LongCat.APIKey = "********"
|
||||
}
|
||||
settings.LongCat.BaseURL = os.Getenv("LONGCAT_BASE_URL")
|
||||
settings.LongCat.OpenAIEndpoint = os.Getenv("LONGCAT_OPENAI_ENDPOINT")
|
||||
settings.LongCat.AnthropicEndpoint = os.Getenv("LONGCAT_ANTHROPIC_ENDPOINT")
|
||||
settings.LongCat.Model = os.Getenv("LONGCAT_MODEL")
|
||||
settings.LongCat.ModelThink = os.Getenv("LONGCAT_MODEL_THINKING")
|
||||
settings.LongCat.ModelThinkUpgraded = os.Getenv("LONGCAT_MODEL_THINKING_UPGRADED")
|
||||
settings.LongCat.Format = os.Getenv("LONGCAT_FORMAT")
|
||||
|
||||
if os.Getenv("GROK_ON") == "true" {
|
||||
settings.Grok.Enabled = true
|
||||
}
|
||||
if os.Getenv("GROK_API_KEY") != "" {
|
||||
settings.Grok.APIKey = "********"
|
||||
}
|
||||
settings.Grok.BaseURL = os.Getenv("GROK_BASE_URL")
|
||||
settings.Grok.Model = os.Getenv("GROK_MODEL")
|
||||
settings.Grok.ModelThink = os.Getenv("GROK_MODEL_THINKING")
|
||||
|
||||
if os.Getenv("DEEPSEEK_ON") == "true" {
|
||||
settings.DeepSeek.Enabled = true
|
||||
}
|
||||
if os.Getenv("DEEPSEEK_API_KEY") != "" {
|
||||
settings.DeepSeek.APIKey = "********"
|
||||
}
|
||||
settings.DeepSeek.BaseURL = os.Getenv("DEEPSEEK_BASE_URL")
|
||||
settings.DeepSeek.Model = os.Getenv("DEEPSEEK_MODEL")
|
||||
settings.DeepSeek.ModelThink = os.Getenv("DEEPSEEK_MODEL_THINKING")
|
||||
|
||||
if os.Getenv("OLLAMA_ON") == "true" {
|
||||
settings.Ollama.Enabled = true
|
||||
}
|
||||
settings.Ollama.BaseURL = os.Getenv("OLLAMA_BASE_URL")
|
||||
settings.Ollama.Model = os.Getenv("OLLAMA_MODEL")
|
||||
settings.Ollama.ModelThink = os.Getenv("OLLAMA_MODEL_THINKING")
|
||||
|
||||
if os.Getenv("OPENROUTER_ON") == "true" {
|
||||
settings.OpenRouter.Enabled = true
|
||||
}
|
||||
if os.Getenv("OPENROUTER_API_KEY") != "" {
|
||||
settings.OpenRouter.APIKey = "********"
|
||||
}
|
||||
settings.OpenRouter.BaseURL = os.Getenv("OPENROUTER_BASE_URL")
|
||||
settings.OpenRouter.Model = os.Getenv("OPENROUTER_MODEL")
|
||||
settings.OpenRouter.ModelThink = os.Getenv("OPENROUTER_MODEL_THINKING")
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
func maskAPIKey(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
if len(key) <= 8 {
|
||||
return "********"
|
||||
}
|
||||
return key[:4] + "********" + key[len(key)-4:]
|
||||
}
|
||||
|
||||
func isMasked(key string) bool {
|
||||
return key == "" || (len(key) > 8 && key[4:12] == "********")
|
||||
}
|
||||
|
||||
func getBoolEnv(key string, defaultValue bool) bool {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
boolValue, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return boolValue
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,383 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// GetAuditLogs retrieves audit logs with filtering and pagination
|
||||
func GetAuditLogs(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := user.(models.User)
|
||||
db := config.GetDB()
|
||||
|
||||
// Parse query parameters
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
action := c.Query("action")
|
||||
resource := c.Query("resource")
|
||||
userID := c.Query("user_id")
|
||||
startDate := c.Query("start_date")
|
||||
endDate := c.Query("end_date")
|
||||
riskLevel := c.Query("risk_level")
|
||||
success := c.Query("success")
|
||||
|
||||
// Build query
|
||||
query := db.Model(&models.AuditLog{})
|
||||
|
||||
// Non-admin users can only see their own logs
|
||||
if currentUser.Role != "admin" {
|
||||
query = query.Where("user_id = ?", currentUser.ID)
|
||||
} else if userID != "" {
|
||||
// Admin can filter by specific user
|
||||
if uid, err := strconv.ParseUint(userID, 10, 32); err == nil {
|
||||
query = query.Where("user_id = ?", uid)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
if action != "" {
|
||||
query = query.Where("action = ?", action)
|
||||
}
|
||||
if resource != "" {
|
||||
query = query.Where("resource = ?", resource)
|
||||
}
|
||||
if riskLevel != "" {
|
||||
query = query.Where("risk_level = ?", riskLevel)
|
||||
}
|
||||
if success != "" {
|
||||
query = query.Where("success = ?", success == "true")
|
||||
}
|
||||
if startDate != "" {
|
||||
if start, err := time.Parse("2006-01-02", startDate); err == nil {
|
||||
query = query.Where("created_at >= ?", start)
|
||||
}
|
||||
}
|
||||
if endDate != "" {
|
||||
if end, err := time.Parse("2006-01-02", endDate); err == nil {
|
||||
query = query.Where("created_at <= ?", end.Add(24*time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
// Count total records
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
// Get paginated results
|
||||
offset := (page - 1) * limit
|
||||
var logs []models.AuditLog
|
||||
query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&logs)
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"logs": logs,
|
||||
"pagination": gin.H{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"pages": (total + int64(limit) - 1) / int64(limit),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetAuditLogStats retrieves audit log statistics
|
||||
func GetAuditLogStats(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := user.(models.User)
|
||||
db := config.GetDB()
|
||||
|
||||
// Parse date range
|
||||
startDate := c.DefaultQuery("start_date", time.Now().AddDate(0, -1, 0).Format("2006-01-02"))
|
||||
endDate := c.DefaultQuery("end_date", time.Now().Format("2006-01-02"))
|
||||
|
||||
start, _ := time.Parse("2006-01-02", startDate)
|
||||
end, _ := time.Parse("2006-01-02", endDate)
|
||||
end = end.Add(24 * time.Hour) // Include the entire end date
|
||||
|
||||
// Base query
|
||||
baseQuery := db.Model(&models.AuditLog{}).Where("created_at >= ? AND created_at <= ?", start, end)
|
||||
|
||||
// Non-admin users can only see their own stats
|
||||
if currentUser.Role != "admin" {
|
||||
baseQuery = baseQuery.Where("user_id = ?", currentUser.ID)
|
||||
}
|
||||
|
||||
// Get overall stats
|
||||
var totalLogs, successLogs, failedLogs, suspiciousLogs int64
|
||||
baseQuery.Count(&totalLogs)
|
||||
baseQuery.Where("success = ?", true).Count(&successLogs)
|
||||
baseQuery.Where("success = ?", false).Count(&failedLogs)
|
||||
baseQuery.Where("suspicious = ?", true).Count(&suspiciousLogs)
|
||||
|
||||
// Get action breakdown
|
||||
type ActionStat struct {
|
||||
Action string `json:"action"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
var actionStats []ActionStat
|
||||
baseQuery.Select("action, COUNT(*) as count").Group("action").Order("count DESC").Scan(&actionStats)
|
||||
|
||||
// Get resource breakdown
|
||||
type ResourceStat struct {
|
||||
Resource string `json:"resource"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
var resourceStats []ResourceStat
|
||||
baseQuery.Select("resource, COUNT(*) as count").Group("resource").Order("count DESC").Scan(&resourceStats)
|
||||
|
||||
// Get risk level breakdown
|
||||
type RiskStat struct {
|
||||
RiskLevel string `json:"risk_level"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
var riskStats []RiskStat
|
||||
baseQuery.Select("risk_level, COUNT(*) as count").Group("risk_level").Order("count DESC").Scan(&riskStats)
|
||||
|
||||
// Get daily activity for the last 30 days
|
||||
type DailyStat struct {
|
||||
Date string `json:"date"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
var dailyStats []DailyStat
|
||||
dailyQuery := db.Model(&models.AuditLog{}).
|
||||
Select("DATE(created_at) as date, COUNT(*) as count").
|
||||
Where("created_at >= ? AND created_at <= ?", start, end).
|
||||
Group("DATE(created_at)").
|
||||
Order("date ASC")
|
||||
|
||||
if currentUser.Role != "admin" {
|
||||
dailyQuery = dailyQuery.Where("user_id = ?", currentUser.ID)
|
||||
}
|
||||
|
||||
dailyQuery.Scan(&dailyStats)
|
||||
|
||||
// Get top users (admin only)
|
||||
var topUsers []struct {
|
||||
UserEmail string `json:"user_email"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
if currentUser.Role == "admin" {
|
||||
baseQuery.Select("user_email, COUNT(*) as count").
|
||||
Group("user_email").
|
||||
Order("count DESC").
|
||||
Limit(10).
|
||||
Scan(&topUsers)
|
||||
}
|
||||
|
||||
// Get recent security events
|
||||
var securityEvents []models.AuditLog
|
||||
securityQuery := db.Model(&models.AuditLog{}).
|
||||
Where("resource = ? AND created_at >= ? AND created_at <= ?",
|
||||
models.AuditResourceSecurity, start, end).
|
||||
Order("created_at DESC").
|
||||
Limit(20)
|
||||
|
||||
if currentUser.Role != "admin" {
|
||||
securityQuery = securityQuery.Where("user_id = ?", currentUser.ID)
|
||||
}
|
||||
|
||||
securityQuery.Find(&securityEvents)
|
||||
|
||||
stats := gin.H{
|
||||
"period": gin.H{
|
||||
"start_date": startDate,
|
||||
"end_date": endDate,
|
||||
},
|
||||
"overview": gin.H{
|
||||
"total_logs": totalLogs,
|
||||
"success_logs": successLogs,
|
||||
"failed_logs": failedLogs,
|
||||
"suspicious_logs": suspiciousLogs,
|
||||
"success_rate": float64(successLogs) / float64(totalLogs) * 100,
|
||||
},
|
||||
"actions": actionStats,
|
||||
"resources": resourceStats,
|
||||
"risk_levels": riskStats,
|
||||
"daily_activity": dailyStats,
|
||||
"security_events": securityEvents,
|
||||
}
|
||||
|
||||
if currentUser.Role == "admin" {
|
||||
stats["top_users"] = topUsers
|
||||
}
|
||||
|
||||
c.JSON(200, stats)
|
||||
}
|
||||
|
||||
// GetAuditLog retrieves a specific audit log entry
|
||||
func GetAuditLog(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := user.(models.User)
|
||||
logID := c.Param("id")
|
||||
|
||||
db := config.GetDB()
|
||||
|
||||
var log models.AuditLog
|
||||
query := db.Where("id = ?", logID)
|
||||
|
||||
// Non-admin users can only see their own logs
|
||||
if currentUser.Role != "admin" {
|
||||
query = query.Where("user_id = ?", currentUser.ID)
|
||||
}
|
||||
|
||||
if err := query.First(&log).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(404, gin.H{"error": "Audit log not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(500, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"log": log})
|
||||
}
|
||||
|
||||
// ExportAuditLogs exports audit logs in various formats
|
||||
func ExportAuditLogs(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := user.(models.User)
|
||||
format := c.DefaultQuery("format", "json") // json, csv, xlsx
|
||||
|
||||
// Only admin can export logs
|
||||
if currentUser.Role != "admin" {
|
||||
c.JSON(403, gin.H{"error": "Admin access required"})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
|
||||
// Parse query parameters (same as GetAuditLogs)
|
||||
startDate := c.DefaultQuery("start_date", time.Now().AddDate(0, -1, 0).Format("2006-01-02"))
|
||||
endDate := c.DefaultQuery("end_date", time.Now().Format("2006-01-02"))
|
||||
action := c.Query("action")
|
||||
resource := c.Query("resource")
|
||||
userID := c.Query("user_id")
|
||||
riskLevel := c.Query("risk_level")
|
||||
|
||||
// Build query
|
||||
query := db.Model(&models.AuditLog{})
|
||||
|
||||
if startDate != "" {
|
||||
if start, err := time.Parse("2006-01-02", startDate); err == nil {
|
||||
query = query.Where("created_at >= ?", start)
|
||||
}
|
||||
}
|
||||
if endDate != "" {
|
||||
if end, err := time.Parse("2006-01-02", endDate); err == nil {
|
||||
query = query.Where("created_at <= ?", end.Add(24*time.Hour))
|
||||
}
|
||||
}
|
||||
if action != "" {
|
||||
query = query.Where("action = ?", action)
|
||||
}
|
||||
if resource != "" {
|
||||
query = query.Where("resource = ?", resource)
|
||||
}
|
||||
if userID != "" {
|
||||
if uid, err := strconv.ParseUint(userID, 10, 32); err == nil {
|
||||
query = query.Where("user_id = ?", uid)
|
||||
}
|
||||
}
|
||||
if riskLevel != "" {
|
||||
query = query.Where("risk_level = ?", riskLevel)
|
||||
}
|
||||
|
||||
var logs []models.AuditLog
|
||||
query.Order("created_at DESC").Find(&logs)
|
||||
|
||||
switch format {
|
||||
case "csv":
|
||||
c.Header("Content-Type", "text/csv")
|
||||
c.Header("Content-Disposition", "attachment; filename=audit_logs.csv")
|
||||
// Generate CSV (simplified)
|
||||
c.String(200, generateCSV(logs))
|
||||
case "xlsx":
|
||||
// For Excel export, you'd need a library like excelize
|
||||
c.JSON(501, gin.H{"error": "Excel export not implemented yet"})
|
||||
default:
|
||||
c.Header("Content-Type", "application/json")
|
||||
c.Header("Content-Disposition", "attachment; filename=audit_logs.json")
|
||||
c.JSON(200, logs)
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupAuditLogs removes old audit logs based on retention policy
|
||||
func CleanupAuditLogs(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := user.(models.User)
|
||||
|
||||
// Only admin can cleanup logs
|
||||
if currentUser.Role != "admin" {
|
||||
c.JSON(403, gin.H{"error": "Admin access required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse retention period (default 90 days)
|
||||
retentionDays, _ := strconv.Atoi(c.DefaultQuery("retention_days", "90"))
|
||||
cutoffDate := time.Now().AddDate(0, 0, -retentionDays)
|
||||
|
||||
db := config.GetDB()
|
||||
|
||||
// Delete old logs
|
||||
result := db.Where("created_at < ?", cutoffDate).Delete(&models.AuditLog{})
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"message": "Audit logs cleanup completed",
|
||||
"deleted_count": result.RowsAffected,
|
||||
"retention_days": retentionDays,
|
||||
"cutoff_date": cutoffDate,
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to generate CSV (simplified implementation)
|
||||
func generateCSV(logs []models.AuditLog) string {
|
||||
var csv string
|
||||
csv = "ID,User Email,Action,Resource,Resource ID,Description,Success,Risk Level,Created At\n"
|
||||
|
||||
for _, log := range logs {
|
||||
csv += fmt.Sprintf("%d,%s,%s,%s,%v,%s,%v,%s,%s\n",
|
||||
log.ID,
|
||||
log.UserEmail,
|
||||
log.Action,
|
||||
log.Resource,
|
||||
log.ResourceID,
|
||||
log.Description,
|
||||
log.Success,
|
||||
log.RiskLevel,
|
||||
log.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
)
|
||||
}
|
||||
|
||||
return csv
|
||||
}
|
||||
+397
-2
@@ -1,8 +1,12 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -31,6 +35,24 @@ type AuthResponse struct {
|
||||
User models.User `json:"user"`
|
||||
}
|
||||
|
||||
type PasswordResetRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
|
||||
type PasswordResetConfirm struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
type PasswordResetCode struct {
|
||||
ID uint `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Used bool `json:"used"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// JWT Claims structure
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
@@ -73,9 +95,47 @@ func ValidateJWT(tokenString string) (*Claims, error) {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
// AuthMiddleware middleware to protect routes
|
||||
// AuthMiddleware validates JWT tokens
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Check if demo mode is enabled
|
||||
if os.Getenv("VITE_DEMO_MODE") == "true" {
|
||||
path := c.Request.URL.Path
|
||||
// Set a demo user for specific routes in demo mode
|
||||
if strings.Contains(path, "/youtube") ||
|
||||
strings.Contains(path, "/learning-paths") ||
|
||||
strings.Contains(path, "/bookmarks") ||
|
||||
strings.Contains(path, "/tasks") ||
|
||||
strings.Contains(path, "/notes") ||
|
||||
strings.Contains(path, "/files") ||
|
||||
strings.Contains(path, "/time-entries") ||
|
||||
strings.Contains(path, "/calendar") ||
|
||||
strings.Contains(path, "/ai/settings") ||
|
||||
strings.Contains(path, "/ai/providers") ||
|
||||
strings.Contains(path, "/ai/test-connection") ||
|
||||
strings.Contains(path, "/search") ||
|
||||
strings.Contains(path, "/dashboard/stats") {
|
||||
// Set a demo user for these routes in demo mode
|
||||
c.Set("user", models.User{
|
||||
ID: 1,
|
||||
Username: "demo",
|
||||
Email: "[email protected]",
|
||||
})
|
||||
c.Set("user_id", uint(1))
|
||||
c.Set("userID", uint(1)) // Add this for compatibility with handlers
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Skip auth for AI settings in demo mode for testing
|
||||
if os.Getenv("VITE_DEMO_MODE") == "true" && strings.Contains(c.Request.URL.Path, "/ai/settings") {
|
||||
c.Set("user_id", uint(1))
|
||||
c.Set("userID", uint(1))
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(401, gin.H{"error": "Authorization header required"})
|
||||
@@ -105,11 +165,28 @@ func AuthMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
c.Set("user", user)
|
||||
c.Set("userID", user.ID)
|
||||
c.Set("user_id", user.ID)
|
||||
c.Set("userID", user.ID) // Add this for compatibility with handlers
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// CheckUsers checks if any users exist in the system
|
||||
func CheckUsers(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&models.User{}).Count(&count).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to check users"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"hasUsers": count > 0,
|
||||
"count": count,
|
||||
})
|
||||
}
|
||||
|
||||
// Register handles user registration
|
||||
func Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
@@ -317,3 +394,321 @@ func ChangePassword(c *gin.Context) {
|
||||
func Logout(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"message": "Logged out successfully"})
|
||||
}
|
||||
|
||||
// generateResetCode generates a cryptographically secure 8-character reset code
|
||||
func generateResetCode() (string, error) {
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
bytes := make([]byte, 8)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i, b := range bytes {
|
||||
bytes[i] = charset[b%byte(len(charset))]
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// sendResetEmail sends a password reset email
|
||||
func sendResetEmail(email, code string) error {
|
||||
smtpHost := os.Getenv("SMTP_HOST")
|
||||
smtpPort := os.Getenv("SMTP_PORT")
|
||||
smtpUsername := os.Getenv("SMTP_USERNAME")
|
||||
smtpPassword := os.Getenv("SMTP_PASSWORD")
|
||||
fromEmail := os.Getenv("SMTP_FROM_EMAIL")
|
||||
fromName := os.Getenv("SMTP_FROM_NAME")
|
||||
|
||||
if smtpHost == "" || smtpUsername == "" || smtpPassword == "" || fromEmail == "" {
|
||||
return errors.New("SMTP configuration not complete")
|
||||
}
|
||||
|
||||
// Create auth
|
||||
auth := smtp.PlainAuth("", smtpUsername, smtpPassword, smtpHost)
|
||||
|
||||
// Compose message
|
||||
subject := "Password Reset - Trackeep"
|
||||
body := fmt.Sprintf(`
|
||||
Hello,
|
||||
|
||||
You requested a password reset for your Trackeep account.
|
||||
|
||||
Your reset code is: %s
|
||||
|
||||
This code will expire in 15 minutes.
|
||||
|
||||
If you didn't request this, please ignore this email.
|
||||
|
||||
Best regards,
|
||||
%s
|
||||
`, code, fromName)
|
||||
|
||||
msg := fmt.Sprintf("From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\n\r\n%s",
|
||||
fromName, fromEmail, email, subject, body)
|
||||
|
||||
// Send email
|
||||
addr := fmt.Sprintf("%s:%s", smtpHost, smtpPort)
|
||||
return smtp.SendMail(addr, auth, fromEmail, []string{email}, []byte(msg))
|
||||
}
|
||||
|
||||
// RequestPasswordReset handles password reset requests
|
||||
func RequestPasswordReset(c *gin.Context) {
|
||||
var req PasswordResetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
|
||||
// Check if user exists
|
||||
var user models.User
|
||||
if err := db.Where("email = ?", req.Email).First(&user).Error; err != nil {
|
||||
// Don't reveal if user exists or not
|
||||
c.JSON(200, gin.H{"message": "If an account with this email exists, a reset code has been sent"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate reset code
|
||||
code, err := generateResetCode()
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to generate reset code"})
|
||||
return
|
||||
}
|
||||
|
||||
// Store reset code in database (you might want to create a separate table for this)
|
||||
resetCode := PasswordResetCode{
|
||||
Email: req.Email,
|
||||
Code: code,
|
||||
ExpiresAt: time.Now().Add(15 * time.Minute),
|
||||
Used: false,
|
||||
}
|
||||
|
||||
// For now, we'll use a simple approach - in production, you'd want a proper table
|
||||
// Create the reset_codes table if it doesn't exist
|
||||
db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS password_reset_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
used BOOLEAN DEFAULT FALSE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
|
||||
if err := db.Create(&resetCode).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to store reset code"})
|
||||
return
|
||||
}
|
||||
|
||||
// Send email
|
||||
if err := sendResetEmail(req.Email, code); err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to send reset email: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"message": "Reset code sent to your email"})
|
||||
}
|
||||
|
||||
// ConfirmPasswordReset handles password reset confirmation
|
||||
func ConfirmPasswordReset(c *gin.Context) {
|
||||
var req PasswordResetConfirm
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
|
||||
// Find valid reset code
|
||||
var resetCode PasswordResetCode
|
||||
if err := db.Where("code = ? AND used = ? AND expires_at > ?", req.Code, false, time.Now()).First(&resetCode).Error; err != nil {
|
||||
c.JSON(400, gin.H{"error": "Invalid or expired reset code"})
|
||||
return
|
||||
}
|
||||
|
||||
// Find user
|
||||
var user models.User
|
||||
if err := db.Where("email = ?", resetCode.Email).First(&user).Error; err != nil {
|
||||
c.JSON(400, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to hash password"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update password
|
||||
if err := db.Model(&user).Update("password", string(hashedPassword)).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to update password"})
|
||||
return
|
||||
}
|
||||
|
||||
// Mark reset code as used
|
||||
db.Model(&resetCode).Update("used", true)
|
||||
|
||||
c.JSON(200, gin.H{"message": "Password reset successfully"})
|
||||
}
|
||||
|
||||
// GetDashboardStats returns dashboard statistics for the current user
|
||||
func GetDashboardStats(c *gin.Context) {
|
||||
// Check if demo mode is enabled
|
||||
if os.Getenv("VITE_DEMO_MODE") == "true" {
|
||||
// Return mock dashboard stats for demo mode
|
||||
stats := gin.H{
|
||||
"totalBookmarks": 156,
|
||||
"totalTasks": 42,
|
||||
"totalFiles": 234,
|
||||
"totalNotes": 89,
|
||||
"recentActivity": []map[string]interface{}{
|
||||
{
|
||||
"id": 1,
|
||||
"type": "task",
|
||||
"title": "Complete project documentation",
|
||||
"timestamp": "1 hour ago",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "bookmark",
|
||||
"title": "SolidJS Documentation",
|
||||
"timestamp": "2 hours ago",
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "note",
|
||||
"title": "Meeting notes - Q1 planning",
|
||||
"timestamp": "3 hours ago",
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "file",
|
||||
"title": "project-roadmap.pdf",
|
||||
"timestamp": "4 hours ago",
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "task",
|
||||
"title": "Review pull requests",
|
||||
"timestamp": "5 hours ago",
|
||||
},
|
||||
},
|
||||
}
|
||||
c.JSON(200, stats)
|
||||
return
|
||||
}
|
||||
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := user.(models.User)
|
||||
db := config.GetDB()
|
||||
|
||||
// Get counts for each entity type
|
||||
var bookmarkCount, taskCount, fileCount, noteCount int64
|
||||
|
||||
// Count bookmarks
|
||||
db.Model(&models.Bookmark{}).Where("user_id = ?", currentUser.ID).Count(&bookmarkCount)
|
||||
|
||||
// Count tasks
|
||||
db.Model(&models.Task{}).Where("user_id = ?", currentUser.ID).Count(&taskCount)
|
||||
|
||||
// Count files
|
||||
db.Model(&models.File{}).Where("user_id = ?", currentUser.ID).Count(&fileCount)
|
||||
|
||||
// Count notes
|
||||
db.Model(&models.Note{}).Where("user_id = ?", currentUser.ID).Count(¬eCount)
|
||||
|
||||
// Get recent activity
|
||||
type RecentActivity struct {
|
||||
ID uint `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
var activities []RecentActivity
|
||||
|
||||
// Get recent bookmarks
|
||||
var bookmarks []models.Bookmark
|
||||
db.Where("user_id = ?", currentUser.ID).Order("created_at DESC").Limit(3).Find(&bookmarks)
|
||||
for _, bookmark := range bookmarks {
|
||||
activities = append(activities, RecentActivity{
|
||||
ID: bookmark.ID,
|
||||
Type: "bookmark",
|
||||
Title: bookmark.Title,
|
||||
Timestamp: formatTimeAgo(bookmark.CreatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
// Get recent tasks
|
||||
var tasks []models.Task
|
||||
db.Where("user_id = ?", currentUser.ID).Order("created_at DESC").Limit(3).Find(&tasks)
|
||||
for _, task := range tasks {
|
||||
activities = append(activities, RecentActivity{
|
||||
ID: task.ID,
|
||||
Type: "task",
|
||||
Title: task.Title,
|
||||
Timestamp: formatTimeAgo(task.CreatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
// Get recent notes
|
||||
var notes []models.Note
|
||||
db.Where("user_id = ?", currentUser.ID).Order("created_at DESC").Limit(3).Find(¬es)
|
||||
for _, note := range notes {
|
||||
activities = append(activities, RecentActivity{
|
||||
ID: note.ID,
|
||||
Type: "note",
|
||||
Title: note.Title,
|
||||
Timestamp: formatTimeAgo(note.CreatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
// Sort activities by timestamp (most recent first)
|
||||
// For simplicity, we'll just take the first 5
|
||||
if len(activities) > 5 {
|
||||
activities = activities[:5]
|
||||
}
|
||||
|
||||
stats := gin.H{
|
||||
"totalBookmarks": bookmarkCount,
|
||||
"totalTasks": taskCount,
|
||||
"totalFiles": fileCount,
|
||||
"totalNotes": noteCount,
|
||||
"recentActivity": activities,
|
||||
}
|
||||
|
||||
c.JSON(200, stats)
|
||||
}
|
||||
|
||||
// formatTimeAgo formats a time as a relative "time ago" string
|
||||
func formatTimeAgo(t time.Time) string {
|
||||
duration := time.Since(t)
|
||||
|
||||
if duration < time.Hour {
|
||||
minutes := int(duration.Minutes())
|
||||
if minutes == 1 {
|
||||
return "1 minute ago"
|
||||
}
|
||||
return fmt.Sprintf("%d minutes ago", minutes)
|
||||
} else if duration < 24*time.Hour {
|
||||
hours := int(duration.Hours())
|
||||
if hours == 1 {
|
||||
return "1 hour ago"
|
||||
}
|
||||
return fmt.Sprintf("%d hours ago", hours)
|
||||
} else if duration < 7*24*time.Hour {
|
||||
days := int(duration.Hours() / 24)
|
||||
if days == 1 {
|
||||
return "1 day ago"
|
||||
}
|
||||
return fmt.Sprintf("%d days ago", days)
|
||||
} else {
|
||||
return t.Format("Jan 2, 2006")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,62 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
"github.com/trackeep/backend/services"
|
||||
)
|
||||
|
||||
// GetBookmarks handles GET /api/v1/bookmarks
|
||||
func GetBookmarks(c *gin.Context) {
|
||||
// Check if demo mode is enabled
|
||||
if os.Getenv("VITE_DEMO_MODE") == "true" {
|
||||
// Return mock bookmarks for demo mode
|
||||
mockBookmarks := []models.Bookmark{
|
||||
{
|
||||
ID: 1,
|
||||
Title: "React Documentation",
|
||||
URL: "https://react.dev",
|
||||
Description: "The official React documentation",
|
||||
UserID: 1,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Title: "YouTube - Introduction to React Programming",
|
||||
URL: "https://www.youtube.com/watch?v=hTWKbfoikeg",
|
||||
Description: "Video from Programming Tutorials",
|
||||
UserID: 1,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
{
|
||||
ID: 3,
|
||||
Title: "Docker Documentation",
|
||||
URL: "https://docs.docker.com",
|
||||
Description: "Official Docker documentation",
|
||||
UserID: 1,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
c.JSON(http.StatusOK, mockBookmarks)
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
var bookmarks []models.Bookmark
|
||||
|
||||
@@ -48,6 +94,32 @@ func CreateBookmark(c *gin.Context) {
|
||||
}
|
||||
bookmark.UserID = userID
|
||||
|
||||
// Fetch website metadata if URL is provided
|
||||
if bookmark.URL != "" {
|
||||
// Use basic metadata fetching
|
||||
if metadata, err := services.GetCachedMetadata(bookmark.URL); err == nil {
|
||||
// Update bookmark with fetched metadata
|
||||
if bookmark.Title == "" && metadata.Title != "" {
|
||||
bookmark.Title = metadata.Title
|
||||
}
|
||||
if bookmark.Description == "" && metadata.Description != "" {
|
||||
bookmark.Description = metadata.Description
|
||||
}
|
||||
if metadata.Favicon != "" {
|
||||
bookmark.Favicon = metadata.Favicon
|
||||
}
|
||||
if metadata.Author != "" {
|
||||
bookmark.Author = metadata.Author
|
||||
}
|
||||
// Parse published date if available
|
||||
if metadata.PublishedAt != "" {
|
||||
if publishedAt, err := time.Parse(time.RFC3339, metadata.PublishedAt); err == nil {
|
||||
bookmark.PublishedAt = &publishedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create bookmark
|
||||
if err := db.Create(&bookmark).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create bookmark"})
|
||||
@@ -155,3 +227,439 @@ func DeleteBookmark(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Bookmark deleted successfully"})
|
||||
}
|
||||
|
||||
// RefreshBookmarkMetadata handles POST /api/v1/bookmarks/:id/refresh-metadata
|
||||
func RefreshBookmarkMetadata(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bookmark ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var bookmark models.Bookmark
|
||||
userID := c.GetUint("userID")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
// Find existing bookmark
|
||||
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&bookmark).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Bookmark not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch fresh metadata
|
||||
if metadata, err := services.GetCachedMetadata(bookmark.URL); err == nil {
|
||||
// Update bookmark with basic metadata
|
||||
bookmark.Title = metadata.Title
|
||||
bookmark.Description = metadata.Description
|
||||
bookmark.Favicon = metadata.Favicon
|
||||
bookmark.Author = metadata.Author
|
||||
|
||||
// Parse published date if available
|
||||
if metadata.PublishedAt != "" {
|
||||
if publishedAt, err := time.Parse(time.RFC3339, metadata.PublishedAt); err == nil {
|
||||
bookmark.PublishedAt = &publishedAt
|
||||
}
|
||||
}
|
||||
|
||||
// Save updated bookmark
|
||||
if err := db.Save(&bookmark).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bookmark"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get updated bookmark with tags
|
||||
db.Preload("Tags").First(&bookmark, bookmark.ID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Metadata refreshed successfully",
|
||||
"bookmark": bookmark,
|
||||
})
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Failed to fetch metadata: %s", err.Error())})
|
||||
}
|
||||
}
|
||||
|
||||
// GetBookmarkMetadata handles POST /api/v1/bookmarks/metadata
|
||||
func GetBookmarkMetadata(c *gin.Context) {
|
||||
var request struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch metadata using basic service
|
||||
if metadata, err := services.GetCachedMetadata(request.URL); err == nil {
|
||||
// Return metadata from basic fetching
|
||||
response := gin.H{
|
||||
"title": metadata.Title,
|
||||
"description": metadata.Description,
|
||||
"favicon": metadata.Favicon,
|
||||
"metadata": gin.H{
|
||||
"siteName": metadata.SiteName,
|
||||
"description": metadata.Description,
|
||||
"image": metadata.Image,
|
||||
"author": metadata.Author,
|
||||
},
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Failed to fetch metadata: %s", err.Error())})
|
||||
}
|
||||
}
|
||||
|
||||
// GetBookmarkContent handles POST /api/v1/bookmarks/content
|
||||
func GetBookmarkContent(c *gin.Context) {
|
||||
var request struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch full page content with screenshot
|
||||
content, err := fetchPageContentWithScreenshot(request.URL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Failed to fetch content: %s", err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
// Return content as HTML
|
||||
c.Header("Content-Type", "text/html")
|
||||
c.String(http.StatusOK, content)
|
||||
}
|
||||
|
||||
// fetchPageContentWithScreenshot fetches page content and generates a screenshot
|
||||
func fetchPageContentWithScreenshot(targetURL string) (string, error) {
|
||||
// Parse URL to ensure it's valid
|
||||
parsedURL, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
// Create HTTP client with timeout for content fetching
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
// Make request for basic content
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set user agent to avoid being blocked
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
content := string(body)
|
||||
|
||||
// Extract metadata for preview
|
||||
metadata, err := services.FetchWebsiteMetadata(targetURL)
|
||||
if err != nil {
|
||||
// Continue without metadata if it fails
|
||||
metadata = &services.WebsiteMetadata{
|
||||
Title: parsedURL.Hostname(),
|
||||
}
|
||||
}
|
||||
|
||||
// Try to capture screenshot
|
||||
var screenshotData []byte
|
||||
screenshotErr := captureScreenshot(targetURL, &screenshotData)
|
||||
|
||||
// Generate preview HTML with screenshot if available
|
||||
previewHTML := generateEnhancedPreviewHTML(content, metadata, parsedURL, screenshotData, screenshotErr)
|
||||
|
||||
return previewHTML, nil
|
||||
}
|
||||
|
||||
// captureScreenshot captures a screenshot of the given URL using ChromeDP
|
||||
func captureScreenshot(targetURL string, screenshotData *[]byte) error {
|
||||
// Create a new Chrome context
|
||||
ctx, cancel := chromedp.NewContext(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Set a timeout for the entire operation
|
||||
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Navigate to the URL and capture screenshot
|
||||
var buf []byte
|
||||
err := chromedp.Run(ctx,
|
||||
chromedp.Navigate(targetURL),
|
||||
chromedp.WaitReady("body"), // Wait for body to be ready
|
||||
chromedp.EmulateViewport(1200, 800), // Set viewport size
|
||||
chromedp.CaptureScreenshot(&buf),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to capture screenshot: %w", err)
|
||||
}
|
||||
|
||||
*screenshotData = buf
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateEnhancedPreviewHTML creates a clean preview with screenshot
|
||||
func generateEnhancedPreviewHTML(content string, metadata *services.WebsiteMetadata, parsedURL *url.URL, screenshotData []byte, screenshotErr error) string {
|
||||
// Extract main content
|
||||
title := metadata.Title
|
||||
if title == "" {
|
||||
title = parsedURL.Hostname()
|
||||
}
|
||||
|
||||
description := metadata.Description
|
||||
if description == "" {
|
||||
// Try to extract a snippet from the content
|
||||
content = strings.ToLower(content)
|
||||
// Remove script and style tags
|
||||
re := regexp.MustCompile(`(?i)<(script|style)[^>]*>.*?</\1>`)
|
||||
content = re.ReplaceAllString(content, "")
|
||||
|
||||
// Extract text content
|
||||
re = regexp.MustCompile(`<[^>]+>`)
|
||||
textContent := re.ReplaceAllString(content, " ")
|
||||
textContent = strings.TrimSpace(textContent)
|
||||
|
||||
if len(textContent) > 200 {
|
||||
description = textContent[:200] + "..."
|
||||
} else {
|
||||
description = textContent
|
||||
}
|
||||
}
|
||||
|
||||
favicon := metadata.Favicon
|
||||
if favicon == "" {
|
||||
favicon = fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", parsedURL.Host)
|
||||
}
|
||||
|
||||
// Convert screenshot to base64 if available
|
||||
var screenshotHTML string
|
||||
if screenshotErr == nil && len(screenshotData) > 0 {
|
||||
// In a real implementation, you'd encode to base64 and store/display it
|
||||
// For now, we'll add a placeholder
|
||||
screenshotHTML = `
|
||||
<div class="screenshot-container">
|
||||
<h3>Page Screenshot</h3>
|
||||
<div class="screenshot-placeholder">
|
||||
<p>Screenshot captured successfully (${len(screenshotData)} bytes)</p>
|
||||
<p><em>(Screenshot display would be implemented here)</em></p>
|
||||
</div>
|
||||
</div>`
|
||||
} else {
|
||||
screenshotHTML = `
|
||||
<div class="screenshot-container">
|
||||
<h3>Page Screenshot</h3>
|
||||
<div class="screenshot-error">
|
||||
<p>Could not capture screenshot: ` + screenshotErr.Error() + `</p>
|
||||
<p><em>(Screenshot requires Chrome/Chromium to be installed)</em></p>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
// Generate enhanced preview HTML
|
||||
previewHTML := fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Preview: %s</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
.preview-header {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.favicon-container {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.favicon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.header-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.preview-header h1 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
color: #1a1a1a;
|
||||
font-weight: 600;
|
||||
}
|
||||
.preview-url {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
background: #f5f5f5;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.screenshot-container {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
.screenshot-container h3 {
|
||||
margin: 0 0 16px 0;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
}
|
||||
.screenshot-placeholder, .screenshot-error {
|
||||
background: #f8f9fa;
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
}
|
||||
.preview-meta {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
.preview-meta p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
.preview-meta strong {
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
.preview-actions {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
}
|
||||
.visit-site {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.visit-site:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
.site-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.site-info img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="preview-header">
|
||||
<div class="favicon-container">
|
||||
<img src="%s" alt="Site favicon" class="favicon"
|
||||
onerror="this.style.display='none'; this.parentElement.innerHTML='<span style=\'font-size: 18px; font-weight: 600; color: #666;\'>%s</span>'" />
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h1>%s</h1>
|
||||
<div class="preview-url">%s</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
%s
|
||||
|
||||
<div class="preview-meta">
|
||||
<div class="site-info">
|
||||
<img src="%s" alt="Site favicon" style="width: 16px; height: 16px;"
|
||||
onerror="this.style.display='none'" />
|
||||
<strong>Site:</strong> %s
|
||||
</div>
|
||||
<p><strong>Description:</strong> %s</p>
|
||||
<p><strong>Author:</strong> %s</p>
|
||||
</div>
|
||||
|
||||
<div class="preview-actions">
|
||||
<a href="%s" target="_blank" rel="noopener noreferrer" class="visit-site">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15,3 21,3 21,9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
</svg>
|
||||
Visit Original Site
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
title,
|
||||
favicon,
|
||||
title[:1], // First letter for fallback
|
||||
title,
|
||||
parsedURL.String(),
|
||||
screenshotHTML,
|
||||
favicon,
|
||||
metadata.SiteName,
|
||||
description,
|
||||
metadata.Author,
|
||||
parsedURL.String(),
|
||||
)
|
||||
|
||||
return previewHTML
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
type CalendarHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCalendarHandler(db *gorm.DB) *CalendarHandler {
|
||||
return &CalendarHandler{db: db}
|
||||
}
|
||||
|
||||
// CalendarEventRequest represents the request body for creating/updating events
|
||||
type CalendarEventRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
StartTime time.Time `json:"start_time" binding:"required"`
|
||||
EndTime time.Time `json:"end_time" binding:"required"`
|
||||
Type string `json:"type"`
|
||||
Priority string `json:"priority"`
|
||||
Location string `json:"location"`
|
||||
Attendees string `json:"attendees"`
|
||||
Recurring bool `json:"recurring"`
|
||||
Rrule string `json:"rrule"`
|
||||
Source string `json:"source"`
|
||||
TaskID *uint `json:"task_id"`
|
||||
BookmarkID *uint `json:"bookmark_id"`
|
||||
NoteID *uint `json:"note_id"`
|
||||
IsAllDay bool `json:"is_all_day"`
|
||||
ReminderMinutes int `json:"reminder_minutes"`
|
||||
}
|
||||
|
||||
// GetEvents retrieves calendar events for a user
|
||||
func (h *CalendarHandler) GetEvents(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
// Parse query parameters
|
||||
startStr := c.Query("start")
|
||||
endStr := c.Query("end")
|
||||
eventType := c.Query("type")
|
||||
|
||||
var events []models.CalendarEvent
|
||||
query := h.db.Where("user_id = ?", userID)
|
||||
|
||||
// Filter by date range if provided
|
||||
if startStr != "" && endStr != "" {
|
||||
start, err := time.Parse(time.RFC3339, startStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid start date format"})
|
||||
return
|
||||
}
|
||||
end, err := time.Parse(time.RFC3339, endStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end date format"})
|
||||
return
|
||||
}
|
||||
query = query.Where("start_time >= ? AND end_time <= ?", start, end)
|
||||
}
|
||||
|
||||
// Filter by type if provided
|
||||
if eventType != "" {
|
||||
query = query.Where("type = ?", eventType)
|
||||
}
|
||||
|
||||
if err := query.Preload("Task").Preload("Bookmark").Preload("Note").Find(&events).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"events": events})
|
||||
}
|
||||
|
||||
// GetEvent retrieves a single calendar event
|
||||
func (h *CalendarHandler) GetEvent(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
eventID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid event ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var event models.CalendarEvent
|
||||
if err := h.db.Where("id = ? AND user_id = ?", eventID, userID).
|
||||
Preload("Task").Preload("Bookmark").Preload("Note").
|
||||
First(&event).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Event not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch event"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"event": event})
|
||||
}
|
||||
|
||||
// CreateEvent creates a new calendar event
|
||||
func (h *CalendarHandler) CreateEvent(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
var req CalendarEventRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate time range
|
||||
if req.EndTime.Before(req.StartTime) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "End time must be after start time"})
|
||||
return
|
||||
}
|
||||
|
||||
// Set default values
|
||||
if req.Type == "" {
|
||||
req.Type = "reminder"
|
||||
}
|
||||
if req.Priority == "" {
|
||||
req.Priority = "medium"
|
||||
}
|
||||
if req.Source == "" {
|
||||
req.Source = "trackeep"
|
||||
}
|
||||
|
||||
event := models.CalendarEvent{
|
||||
UserID: userID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
StartTime: req.StartTime,
|
||||
EndTime: req.EndTime,
|
||||
Type: req.Type,
|
||||
Priority: req.Priority,
|
||||
Location: req.Location,
|
||||
Attendees: req.Attendees,
|
||||
Recurring: req.Recurring,
|
||||
Rrule: req.Rrule,
|
||||
Source: req.Source,
|
||||
TaskID: req.TaskID,
|
||||
BookmarkID: req.BookmarkID,
|
||||
NoteID: req.NoteID,
|
||||
IsAllDay: req.IsAllDay,
|
||||
ReminderMinutes: req.ReminderMinutes,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&event).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create event"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"event": event})
|
||||
}
|
||||
|
||||
// UpdateEvent updates an existing calendar event
|
||||
func (h *CalendarHandler) UpdateEvent(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
eventID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid event ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var event models.CalendarEvent
|
||||
if err := h.db.Where("id = ? AND user_id = ?", eventID, userID).First(&event).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Event not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch event"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var req CalendarEventRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate time range
|
||||
if req.EndTime.Before(req.StartTime) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "End time must be after start time"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update event fields
|
||||
event.Title = req.Title
|
||||
event.Description = req.Description
|
||||
event.StartTime = req.StartTime
|
||||
event.EndTime = req.EndTime
|
||||
event.Type = req.Type
|
||||
event.Priority = req.Priority
|
||||
event.Location = req.Location
|
||||
event.Attendees = req.Attendees
|
||||
event.Recurring = req.Recurring
|
||||
event.Rrule = req.Rrule
|
||||
event.Source = req.Source
|
||||
event.TaskID = req.TaskID
|
||||
event.BookmarkID = req.BookmarkID
|
||||
event.NoteID = req.NoteID
|
||||
event.IsAllDay = req.IsAllDay
|
||||
event.ReminderMinutes = req.ReminderMinutes
|
||||
|
||||
if err := h.db.Save(&event).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update event"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"event": event})
|
||||
}
|
||||
|
||||
// DeleteEvent deletes a calendar event
|
||||
func (h *CalendarHandler) DeleteEvent(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
eventID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid event ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var event models.CalendarEvent
|
||||
if err := h.db.Where("id = ? AND user_id = ?", eventID, userID).First(&event).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Event not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch event"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Delete(&event).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete event"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Event deleted successfully"})
|
||||
}
|
||||
|
||||
// GetUpcomingEvents retrieves events for the next 7 days
|
||||
func (h *CalendarHandler) GetUpcomingEvents(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
now := time.Now()
|
||||
weekLater := now.AddDate(0, 0, 7)
|
||||
|
||||
var events []models.CalendarEvent
|
||||
if err := h.db.Where("user_id = ? AND start_time >= ? AND start_time <= ?", userID, now, weekLater).
|
||||
Order("start_time ASC").
|
||||
Preload("Task").Preload("Bookmark").Preload("Note").
|
||||
Find(&events).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch upcoming events"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"events": events})
|
||||
}
|
||||
|
||||
// GetTodayEvents retrieves events for today
|
||||
func (h *CalendarHandler) GetTodayEvents(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
now := time.Now()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
endOfDay := startOfDay.Add(24 * time.Hour)
|
||||
|
||||
var events []models.CalendarEvent
|
||||
if err := h.db.Where("user_id = ? AND start_time >= ? AND start_time < ?", userID, startOfDay, endOfDay).
|
||||
Order("start_time ASC").
|
||||
Preload("Task").Preload("Bookmark").Preload("Note").
|
||||
Find(&events).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch today's events"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"events": events})
|
||||
}
|
||||
|
||||
// GetDeadlines retrieves upcoming deadlines
|
||||
func (h *CalendarHandler) GetDeadlines(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
now := time.Now()
|
||||
monthLater := now.AddDate(0, 1, 0)
|
||||
|
||||
var events []models.CalendarEvent
|
||||
if err := h.db.Where("user_id = ? AND type = ? AND start_time >= ? AND start_time <= ?", userID, "deadline", now, monthLater).
|
||||
Order("start_time ASC").
|
||||
Preload("Task").
|
||||
Find(&events).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch deadlines"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deadlines": events})
|
||||
}
|
||||
|
||||
// ToggleEventCompletion toggles the completion status of an event
|
||||
func (h *CalendarHandler) ToggleEventCompletion(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
eventID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid event ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var event models.CalendarEvent
|
||||
if err := h.db.Where("id = ? AND user_id = ?", eventID, userID).First(&event).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Event not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch event"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
event.IsCompleted = !event.IsCompleted
|
||||
if err := h.db.Save(&event).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update event"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"event": event})
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
"github.com/trackeep/backend/services"
|
||||
)
|
||||
|
||||
// MistralConfig holds configuration for Mistral AI
|
||||
type MistralConfig struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Model string
|
||||
MaxTokens int
|
||||
Temperature float64
|
||||
}
|
||||
|
||||
// ChatRequest represents a chat message request
|
||||
type ChatRequest struct {
|
||||
Message string `json:"message" binding:"required"`
|
||||
SessionID *string `json:"session_id,omitempty"`
|
||||
Context map[string]bool `json:"context,omitempty"` // what data to include
|
||||
Provider string `json:"provider,omitempty"` // "mistral", "longcat", "grok", "deepseek", "ollama", "openrouter"
|
||||
ModelType string `json:"model_type,omitempty"` // "standard", "thinking", "upgraded_thinking"
|
||||
}
|
||||
|
||||
// ChatResponse represents a chat response
|
||||
type ChatResponse struct {
|
||||
ID string `json:"id"`
|
||||
Message string `json:"message"`
|
||||
Role string `json:"role"`
|
||||
SessionID string `json:"session_id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Model string `json:"model"`
|
||||
TokenUsage TokenUsage `json:"token_usage"`
|
||||
ContextUsed []string `json:"context_used"`
|
||||
}
|
||||
|
||||
// TokenUsage represents token usage information
|
||||
type TokenUsage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// MistralMessage represents a message for Mistral API
|
||||
type MistralMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// MistralRequest represents a request to Mistral API
|
||||
type MistralRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []MistralMessage `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
}
|
||||
|
||||
// MistralResponse represents a response from Mistral API
|
||||
type MistralResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Index int `json:"index"`
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
var mistralConfig = MistralConfig{
|
||||
APIKey: os.Getenv("MISTRAL_API_KEY"),
|
||||
BaseURL: "https://api.mistral.ai/v1",
|
||||
Model: "mistral-small-latest", // Cheap and capable model
|
||||
MaxTokens: 4000,
|
||||
Temperature: 0.7,
|
||||
}
|
||||
|
||||
// GetMistralConfig returns current Mistral configuration
|
||||
func GetMistralConfig() MistralConfig {
|
||||
return mistralConfig
|
||||
}
|
||||
|
||||
// SendMessage handles chat message requests
|
||||
func SendMessage(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req ChatRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get or create session
|
||||
var session models.ChatSession
|
||||
if req.SessionID != nil {
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", *req.SessionID, userID).First(&session).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Create new session
|
||||
session = models.ChatSession{
|
||||
UserID: userID,
|
||||
Title: fmt.Sprintf("Chat %s", time.Now().Format("Jan 2, 3:04 PM")),
|
||||
IncludeBookmarks: true,
|
||||
IncludeTasks: true,
|
||||
IncludeFiles: true,
|
||||
IncludeNotes: true,
|
||||
}
|
||||
if req.Context != nil {
|
||||
session.IncludeBookmarks = req.Context["bookmarks"]
|
||||
session.IncludeTasks = req.Context["tasks"]
|
||||
session.IncludeFiles = req.Context["files"]
|
||||
session.IncludeNotes = req.Context["notes"]
|
||||
}
|
||||
if err := models.DB.Create(&session).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Save user message
|
||||
userMessage := models.ChatMessage{
|
||||
UserID: userID,
|
||||
SessionID: strconv.Itoa(int(session.ID)),
|
||||
Content: req.Message,
|
||||
Role: "user",
|
||||
}
|
||||
if err := models.DB.Create(&userMessage).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save message"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get conversation history
|
||||
var messages []models.ChatMessage
|
||||
models.DB.Where("session_id = ?", session.ID).Order("created_at asc").Find(&messages)
|
||||
|
||||
// Build context from user data
|
||||
contextData, err := buildUserContext(userID, session)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to build context"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build messages for AI provider (system + history)
|
||||
aiMessages := []services.Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: buildSystemPrompt(contextData),
|
||||
},
|
||||
}
|
||||
|
||||
// Add conversation history (limit to last 10 messages to manage token count)
|
||||
startIdx := 0
|
||||
if len(messages) > 11 { // system + 10 messages
|
||||
startIdx = len(messages) - 10
|
||||
}
|
||||
for i := startIdx; i < len(messages); i++ {
|
||||
aiMessages = append(aiMessages, services.Message{
|
||||
Role: messages[i].Role,
|
||||
Content: messages[i].Content,
|
||||
})
|
||||
}
|
||||
|
||||
// Determine AI provider
|
||||
aiProvider := services.ProviderMistral
|
||||
switch req.Provider {
|
||||
case "longcat":
|
||||
aiProvider = services.ProviderLongCat
|
||||
case "grok":
|
||||
aiProvider = services.ProviderGrok
|
||||
case "deepseek":
|
||||
aiProvider = services.ProviderDeepSeek
|
||||
case "ollama":
|
||||
aiProvider = services.ProviderOllama
|
||||
case "openrouter":
|
||||
aiProvider = services.ProviderOpenRouter
|
||||
}
|
||||
|
||||
aiService := services.NewAIService(aiProvider)
|
||||
aiReq := services.AIRequest{
|
||||
Messages: aiMessages,
|
||||
MaxTokens: 2000,
|
||||
Temperature: 0.7,
|
||||
ModelType: req.ModelType,
|
||||
}
|
||||
|
||||
// Call AI provider
|
||||
startTime := time.Now()
|
||||
var aiResp *services.AIResponse
|
||||
switch req.ModelType {
|
||||
case "thinking":
|
||||
aiResp, err = aiService.ChatCompletionWithThinking(aiReq)
|
||||
case "upgraded_thinking":
|
||||
aiResp, err = aiService.ChatCompletionWithUpgradedThinking(aiReq)
|
||||
default:
|
||||
aiResp, err = aiService.ChatCompletion(aiReq)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to call AI: " + err.Error()})
|
||||
return
|
||||
}
|
||||
processingMs := time.Since(startTime).Milliseconds()
|
||||
|
||||
if len(aiResp.Choices) == 0 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "No response from AI"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract assistant response, handling thinking models where needed
|
||||
assistantContent := services.ParseThinkingResponse(aiResp, aiProvider, req.ModelType)
|
||||
|
||||
// Save assistant message
|
||||
assistantMessage := models.ChatMessage{
|
||||
UserID: userID,
|
||||
SessionID: strconv.Itoa(int(session.ID)),
|
||||
Content: assistantContent,
|
||||
Role: "assistant",
|
||||
ModelUsed: aiResp.Model,
|
||||
TokenCount: aiResp.Usage.TotalTokens,
|
||||
ProcessingMs: processingMs,
|
||||
ContextItems: getContextItemIDs(contextData),
|
||||
}
|
||||
if err := models.DB.Create(&assistantMessage).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save response"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update session
|
||||
session.MessageCount = len(messages) + 1
|
||||
now := time.Now()
|
||||
session.LastMessageAt = &now
|
||||
models.DB.Save(&session)
|
||||
|
||||
// Return response
|
||||
response := ChatResponse{
|
||||
ID: aiResp.ID,
|
||||
Message: assistantContent,
|
||||
Role: "assistant",
|
||||
SessionID: strconv.Itoa(int(session.ID)),
|
||||
Timestamp: time.Now(),
|
||||
Model: aiResp.Model,
|
||||
TokenUsage: TokenUsage{
|
||||
PromptTokens: aiResp.Usage.PromptTokens,
|
||||
CompletionTokens: aiResp.Usage.CompletionTokens,
|
||||
TotalTokens: aiResp.Usage.TotalTokens,
|
||||
},
|
||||
ContextUsed: getContextItemIDs(contextData),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetSessions retrieves user's chat sessions
|
||||
func GetSessions(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var sessions []models.ChatSession
|
||||
models.DB.Where("user_id = ?", userID).Order("updated_at desc").Find(&sessions)
|
||||
|
||||
c.JSON(http.StatusOK, sessions)
|
||||
}
|
||||
|
||||
// GetSessionMessages retrieves messages for a specific session
|
||||
func GetSessionMessages(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
sessionID := c.Param("id")
|
||||
|
||||
// Verify session belongs to user
|
||||
var session models.ChatSession
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var messages []models.ChatMessage
|
||||
models.DB.Where("session_id = ?", sessionID).Order("created_at asc").Find(&messages)
|
||||
|
||||
c.JSON(http.StatusOK, messages)
|
||||
}
|
||||
|
||||
// DeleteSession deletes a chat session and its messages
|
||||
func DeleteSession(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
sessionID := c.Param("id")
|
||||
|
||||
// Verify session belongs to user
|
||||
var session models.ChatSession
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete messages first
|
||||
models.DB.Where("session_id = ?", sessionID).Delete(&models.ChatMessage{})
|
||||
|
||||
// Delete session
|
||||
models.DB.Delete(&session)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Session deleted"})
|
||||
}
|
||||
|
||||
func callMistral(messages []MistralMessage) (*MistralResponse, error) {
|
||||
reqBody := MistralRequest{
|
||||
Model: mistralConfig.Model,
|
||||
Messages: messages,
|
||||
MaxTokens: mistralConfig.MaxTokens,
|
||||
Temperature: mistralConfig.Temperature,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", mistralConfig.BaseURL+"/chat/completions", strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+mistralConfig.APIKey)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Mistral API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var mistralResp MistralResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&mistralResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &mistralResp, nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// UserContext represents the contextual data available to the AI
|
||||
type UserContext struct {
|
||||
Bookmarks []models.Bookmark
|
||||
Tasks []models.Task
|
||||
Files []models.File
|
||||
Notes []models.Note
|
||||
}
|
||||
|
||||
// buildUserContext gathers user data based on session configuration
|
||||
func buildUserContext(userID uint, session models.ChatSession) (*UserContext, error) {
|
||||
context := &UserContext{}
|
||||
|
||||
// Get bookmarks
|
||||
if session.IncludeBookmarks {
|
||||
var bookmarks []models.Bookmark
|
||||
models.DB.Where("user_id = ?", userID).Limit(20).Order("updated_at desc").Find(&bookmarks)
|
||||
context.Bookmarks = bookmarks
|
||||
}
|
||||
|
||||
// Get tasks
|
||||
if session.IncludeTasks {
|
||||
var tasks []models.Task
|
||||
models.DB.Where("user_id = ?", userID).Limit(20).Order("updated_at desc").Find(&tasks)
|
||||
context.Tasks = tasks
|
||||
}
|
||||
|
||||
// Get files
|
||||
if session.IncludeFiles {
|
||||
var files []models.File
|
||||
models.DB.Where("user_id = ?", userID).Limit(20).Order("updated_at desc").Find(&files)
|
||||
context.Files = files
|
||||
}
|
||||
|
||||
// Get notes
|
||||
if session.IncludeNotes {
|
||||
var notes []models.Note
|
||||
models.DB.Where("user_id = ?", userID).Limit(20).Order("updated_at desc").Find(¬es)
|
||||
context.Notes = notes
|
||||
}
|
||||
|
||||
return context, nil
|
||||
}
|
||||
|
||||
// buildSystemPrompt creates a system prompt with user context
|
||||
func buildSystemPrompt(context *UserContext) string {
|
||||
prompt := `You are a helpful AI assistant for Trackeep, a personal productivity and knowledge management platform.
|
||||
You have access to the user's personal data including bookmarks, tasks, files, and notes.
|
||||
Your role is to help them organize, find information, and manage their digital life effectively.
|
||||
|
||||
Key capabilities:
|
||||
- Help find specific bookmarks, tasks, or notes
|
||||
- Suggest organization strategies
|
||||
- Answer questions about their saved content
|
||||
- Help with task planning and prioritization
|
||||
- Assist with learning progress tracking
|
||||
|
||||
Be helpful, concise, and actionable. If you reference specific items, mention their titles or key details.
|
||||
|
||||
--- USER DATA ---`
|
||||
|
||||
// Add bookmarks context
|
||||
if len(context.Bookmarks) > 0 {
|
||||
prompt += "\n\nBOOKMARKS:\n"
|
||||
for i, bookmark := range context.Bookmarks {
|
||||
if i >= 10 { // Limit to prevent token overflow
|
||||
prompt += "... and " + strconv.Itoa(len(context.Bookmarks)-10) + " more bookmarks\n"
|
||||
break
|
||||
}
|
||||
prompt += fmt.Sprintf("- %s: %s", bookmark.Title, bookmark.URL)
|
||||
if bookmark.Description != "" {
|
||||
prompt += " (" + bookmark.Description + ")"
|
||||
}
|
||||
if bookmark.IsFavorite {
|
||||
prompt += " ⭐"
|
||||
}
|
||||
prompt += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
// Add tasks context
|
||||
if len(context.Tasks) > 0 {
|
||||
prompt += "\n\nTASKS:\n"
|
||||
for i, task := range context.Tasks {
|
||||
if i >= 10 {
|
||||
prompt += "... and " + strconv.Itoa(len(context.Tasks)-10) + " more tasks\n"
|
||||
break
|
||||
}
|
||||
status := string(task.Status)
|
||||
priority := string(task.Priority)
|
||||
prompt += fmt.Sprintf("- [%s] %s (Priority: %s)", status, task.Title, priority)
|
||||
if task.DueDate != nil {
|
||||
prompt += " Due: " + task.DueDate.Format("Jan 2")
|
||||
}
|
||||
prompt += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
// Add files context
|
||||
if len(context.Files) > 0 {
|
||||
prompt += "\n\nFILES:\n"
|
||||
for i, file := range context.Files {
|
||||
if i >= 10 {
|
||||
prompt += "... and " + strconv.Itoa(len(context.Files)-10) + " more files\n"
|
||||
break
|
||||
}
|
||||
prompt += fmt.Sprintf("- %s (%s, %s)", file.OriginalName, file.FileType, formatFileSize(file.FileSize))
|
||||
if file.Description != "" {
|
||||
prompt += " - " + file.Description
|
||||
}
|
||||
prompt += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
// Add notes context
|
||||
if len(context.Notes) > 0 {
|
||||
prompt += "\n\nNOTES:\n"
|
||||
for i, note := range context.Notes {
|
||||
if i >= 10 {
|
||||
prompt += "... and " + strconv.Itoa(len(context.Notes)-10) + " more notes\n"
|
||||
break
|
||||
}
|
||||
prompt += fmt.Sprintf("- %s", note.Title)
|
||||
if note.Description != "" {
|
||||
prompt += " - " + note.Description
|
||||
}
|
||||
if note.IsPinned {
|
||||
prompt += " 📌"
|
||||
}
|
||||
prompt += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
prompt += "\n--- END USER DATA ---\n\nNow respond to the user's message based on this context."
|
||||
return prompt
|
||||
}
|
||||
|
||||
// getContextItemIDs extracts IDs from context for tracking
|
||||
func getContextItemIDs(context *UserContext) []string {
|
||||
var ids []string
|
||||
|
||||
for _, bookmark := range context.Bookmarks {
|
||||
ids = append(ids, "bookmark:"+strconv.Itoa(int(bookmark.ID)))
|
||||
}
|
||||
|
||||
for _, task := range context.Tasks {
|
||||
ids = append(ids, "task:"+strconv.Itoa(int(task.ID)))
|
||||
}
|
||||
|
||||
for _, file := range context.Files {
|
||||
ids = append(ids, "file:"+strconv.Itoa(int(file.ID)))
|
||||
}
|
||||
|
||||
for _, note := range context.Notes {
|
||||
ids = append(ids, "note:"+strconv.Itoa(int(note.ID)))
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
// formatFileSize formats file size in human readable format
|
||||
func formatFileSize(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CommunityHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCommunityHandler(db *gorm.DB) *CommunityHandler {
|
||||
return &CommunityHandler{db: db}
|
||||
}
|
||||
|
||||
// === CHALLENGE HANDLERS ===
|
||||
|
||||
// GetChallenges returns all challenges with filtering
|
||||
func (h *CommunityHandler) GetChallenges(c *gin.Context) {
|
||||
var challenges []models.Challenge
|
||||
query := h.db.Preload("Creator").Preload("Tags")
|
||||
|
||||
// Filter by status
|
||||
if status := c.Query("status"); status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
} else {
|
||||
// Default to active challenges for public view
|
||||
query = query.Where("status = ? AND is_public = ?", "active", true)
|
||||
}
|
||||
|
||||
// Filter by category
|
||||
if category := c.Query("category"); category != "" {
|
||||
query = query.Where("category = ?", category)
|
||||
}
|
||||
|
||||
// Filter by difficulty
|
||||
if difficulty := c.Query("difficulty"); difficulty != "" {
|
||||
query = query.Where("difficulty = ?", difficulty)
|
||||
}
|
||||
|
||||
// Filter by featured
|
||||
if featured := c.Query("featured"); featured == "true" {
|
||||
query = query.Where("is_featured = ?", true)
|
||||
}
|
||||
|
||||
// Search by title or description
|
||||
if search := c.Query("search"); search != "" {
|
||||
query = query.Where("title ILIKE ? OR description ILIKE ?", "%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
|
||||
// Sort by
|
||||
sortBy := c.DefaultQuery("sort", "created_at")
|
||||
switch sortBy {
|
||||
case "participants":
|
||||
query = query.Order("participant_count DESC")
|
||||
case "completion_rate":
|
||||
query = query.Order("completion_rate DESC")
|
||||
case "start_date":
|
||||
query = query.Order("start_date ASC")
|
||||
case "created_at":
|
||||
query = query.Order("created_at DESC")
|
||||
default:
|
||||
query = query.Order("created_at DESC")
|
||||
}
|
||||
|
||||
// Pagination
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset := (page - 1) * limit
|
||||
|
||||
var total int64
|
||||
query.Model(&models.Challenge{}).Count(&total)
|
||||
|
||||
if err := query.Offset(offset).Limit(limit).Find(&challenges).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch challenges"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"challenges": challenges,
|
||||
"pagination": gin.H{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"pages": (total + int64(limit) - 1) / int64(limit),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetChallenge returns a specific challenge
|
||||
func (h *CommunityHandler) GetChallenge(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var challenge models.Challenge
|
||||
|
||||
if err := h.db.Preload("Creator").Preload("Tags").Preload("Milestones").Preload("Resources").First(&challenge, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Challenge not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch challenge"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, challenge)
|
||||
}
|
||||
|
||||
// CreateChallenge creates a new challenge
|
||||
func (h *CommunityHandler) CreateChallenge(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
var challenge models.Challenge
|
||||
|
||||
if err := c.ShouldBindJSON(&challenge); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
challenge.CreatorID = userID
|
||||
|
||||
if err := h.db.Create(&challenge).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create challenge"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, challenge)
|
||||
}
|
||||
|
||||
// JoinChallenge allows a user to join a challenge
|
||||
func (h *CommunityHandler) JoinChallenge(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
challengeID := c.Param("id")
|
||||
|
||||
// Check if challenge exists and is active
|
||||
var challenge models.Challenge
|
||||
if err := h.db.First(&challenge, challengeID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Challenge not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if challenge.Status != "active" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Challenge is not active"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is already a participant
|
||||
var existingParticipant models.ChallengeParticipant
|
||||
if err := h.db.Where("challenge_id = ? AND user_id = ?", challengeID, userID).First(&existingParticipant).Error; err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "You are already participating in this challenge"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if challenge has max participants limit
|
||||
if challenge.MaxParticipants != nil {
|
||||
var participantCount int64
|
||||
h.db.Model(&models.ChallengeParticipant{}).Where("challenge_id = ?", challengeID).Count(&participantCount)
|
||||
if participantCount >= int64(*challenge.MaxParticipants) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Challenge has reached maximum participants"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Create participant
|
||||
participant := models.ChallengeParticipant{
|
||||
ChallengeID: challenge.ID,
|
||||
UserID: userID,
|
||||
Status: "joined",
|
||||
StartedAt: &time.Time{},
|
||||
}
|
||||
*participant.StartedAt = time.Now()
|
||||
|
||||
if err := h.db.Create(&participant).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to join challenge"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update challenge participant count
|
||||
h.db.Model(&challenge).UpdateColumn("participant_count", gorm.Expr("participant_count + 1"))
|
||||
|
||||
c.JSON(http.StatusCreated, participant)
|
||||
}
|
||||
|
||||
// GetMyChallenges returns current user's challenge participations
|
||||
func (h *CommunityHandler) GetMyChallenges(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
var participations []models.ChallengeParticipant
|
||||
|
||||
if err := h.db.Preload("Challenge").Preload("Challenge.Creator").Where("user_id = ?", userID).Find(&participations).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch your challenges"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, participations)
|
||||
}
|
||||
|
||||
// UpdateChallengeProgress updates a user's progress in a challenge
|
||||
func (h *CommunityHandler) UpdateChallengeProgress(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
challengeID := c.Param("id")
|
||||
|
||||
var req struct {
|
||||
Progress float64 `json:"progress" binding:"required,min=0,max=100"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var participant models.ChallengeParticipant
|
||||
if err := h.db.Where("challenge_id = ? AND user_id = ?", challengeID, userID).First(&participant).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Challenge participation not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update progress
|
||||
participant.Progress = req.Progress
|
||||
participant.Notes = req.Notes
|
||||
participant.LastActivityAt = &time.Time{}
|
||||
*participant.LastActivityAt = time.Now()
|
||||
|
||||
// Update status based on progress
|
||||
if req.Progress >= 100 && participant.Status != "completed" {
|
||||
participant.Status = "completed"
|
||||
participant.CompletedAt = &time.Time{}
|
||||
*participant.CompletedAt = time.Now()
|
||||
} else if req.Progress > 0 && participant.Status == "joined" {
|
||||
participant.Status = "in_progress"
|
||||
}
|
||||
|
||||
if err := h.db.Save(&participant).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update progress"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update challenge completion count and rate
|
||||
h.db.Model(&models.Challenge{}).Where("id = ?", challengeID).UpdateColumn("completion_count",
|
||||
gorm.Expr("(SELECT COUNT(*) FROM challenge_participants WHERE challenge_id = ? AND status = 'completed')", challengeID))
|
||||
|
||||
c.JSON(http.StatusOK, participant)
|
||||
}
|
||||
|
||||
// === MENTORSHIP HANDLERS ===
|
||||
|
||||
// GetMentorshipRequests returns mentorship requests for the current user
|
||||
func (h *CommunityHandler) GetMentorshipRequests(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
role := c.Query("role") // sent, received
|
||||
|
||||
var requests []models.MentorshipRequest
|
||||
query := h.db.Preload("FromUser").Preload("ToUser")
|
||||
|
||||
if role == "sent" {
|
||||
query = query.Where("from_user_id = ?", userID)
|
||||
} else if role == "received" {
|
||||
query = query.Where("to_user_id = ?", userID)
|
||||
} else {
|
||||
query = query.Where("from_user_id = ? OR to_user_id = ?", userID, userID)
|
||||
}
|
||||
|
||||
if err := query.Order("created_at DESC").Find(&requests).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch mentorship requests"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, requests)
|
||||
}
|
||||
|
||||
// CreateMentorshipRequest creates a new mentorship request
|
||||
func (h *CommunityHandler) CreateMentorshipRequest(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
var request models.MentorshipRequest
|
||||
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
request.FromUserID = userID
|
||||
|
||||
// Calculate match score (simplified version)
|
||||
request.MatchScore = calculateMatchScore(request)
|
||||
|
||||
if err := h.db.Create(&request).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create mentorship request"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, request)
|
||||
}
|
||||
|
||||
// RespondToMentorshipRequest responds to a mentorship request
|
||||
func (h *CommunityHandler) RespondToMentorshipRequest(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
requestID := c.Param("id")
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status" binding:"required"` // accepted, rejected
|
||||
Response string `json:"response"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var request models.MentorshipRequest
|
||||
if err := h.db.First(&request, requestID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Mentorship request not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is the recipient
|
||||
if request.ToUserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "You can only respond to requests sent to you"})
|
||||
return
|
||||
}
|
||||
|
||||
if request.Status != "pending" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Request has already been responded to"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update request
|
||||
request.Status = req.Status
|
||||
request.Response = req.Response
|
||||
request.RespondedAt = &time.Time{}
|
||||
*request.RespondedAt = time.Now()
|
||||
|
||||
if err := h.db.Save(&request).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to respond to request"})
|
||||
return
|
||||
}
|
||||
|
||||
// If accepted, create mentorship
|
||||
if req.Status == "accepted" {
|
||||
mentorship := models.Mentorship{
|
||||
MentorID: request.FromUserID,
|
||||
MenteeID: request.ToUserID,
|
||||
Category: request.Category,
|
||||
Description: request.Description,
|
||||
Goals: request.Goals,
|
||||
StartDate: time.Now(),
|
||||
Status: "active",
|
||||
IsPaid: request.IsPaid,
|
||||
Rate: request.Rate,
|
||||
Currency: request.Currency,
|
||||
SessionLimit: request.Duration,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&mentorship).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create mentorship"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, request)
|
||||
}
|
||||
|
||||
// GetMyMentorships returns current user's mentorships
|
||||
func (h *CommunityHandler) GetMyMentorships(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
role := c.Query("role") // mentor, mentee
|
||||
|
||||
var mentorships []models.Mentorship
|
||||
query := h.db.Preload("Mentor").Preload("Mentee")
|
||||
|
||||
if role == "mentor" {
|
||||
query = query.Where("mentor_id = ?", userID)
|
||||
} else if role == "mentee" {
|
||||
query = query.Where("mentee_id = ?", userID)
|
||||
} else {
|
||||
query = query.Where("mentor_id = ? OR mentee_id = ?", userID, userID)
|
||||
}
|
||||
|
||||
if err := query.Order("created_at DESC").Find(&mentorships).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch mentorships"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, mentorships)
|
||||
}
|
||||
|
||||
// CreateMentorshipSession creates a new mentoring session
|
||||
func (h *CommunityHandler) CreateMentorshipSession(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
mentorshipID := c.Param("id")
|
||||
|
||||
// Check if user is part of this mentorship
|
||||
var mentorship models.Mentorship
|
||||
if err := h.db.First(&mentorship, mentorshipID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Mentorship not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if mentorship.MentorID != userID && mentorship.MenteeID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "You are not part of this mentorship"})
|
||||
return
|
||||
}
|
||||
|
||||
var session models.MentorshipSession
|
||||
if err := c.ShouldBindJSON(&session); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
session.MentorshipID = mentorship.ID
|
||||
|
||||
if err := h.db.Create(&session).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, session)
|
||||
}
|
||||
|
||||
// GetMentorshipSessions returns sessions for a mentorship
|
||||
func (h *CommunityHandler) GetMentorshipSessions(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
mentorshipID := c.Param("id")
|
||||
|
||||
// Check if user is part of this mentorship
|
||||
var mentorship models.Mentorship
|
||||
if err := h.db.First(&mentorship, mentorshipID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Mentorship not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if mentorship.MentorID != userID && mentorship.MenteeID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "You are not part of this mentorship"})
|
||||
return
|
||||
}
|
||||
|
||||
var sessions []models.MentorshipSession
|
||||
if err := h.db.Where("mentorship_id = ?", mentorshipID).Order("scheduled_for DESC").Find(&sessions).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch sessions"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, sessions)
|
||||
}
|
||||
|
||||
// GetCommunityStats returns community statistics
|
||||
func (h *CommunityHandler) GetCommunityStats(c *gin.Context) {
|
||||
var stats struct {
|
||||
ActiveChallenges int64 `json:"active_challenges"`
|
||||
TotalParticipants int64 `json:"total_participants"`
|
||||
ActiveMentorships int64 `json:"active_mentorships"`
|
||||
TotalMentorshipHours float64 `json:"total_mentorship_hours"`
|
||||
PendingRequests int64 `json:"pending_requests"`
|
||||
CompletedChallenges int64 `json:"completed_challenges"`
|
||||
}
|
||||
|
||||
h.db.Model(&models.Challenge{}).Where("status = ?", "active").Count(&stats.ActiveChallenges)
|
||||
h.db.Model(&models.ChallengeParticipant{}).Count(&stats.TotalParticipants)
|
||||
h.db.Model(&models.Mentorship{}).Where("status = ?", "active").Count(&stats.ActiveMentorships)
|
||||
h.db.Model(&models.Mentorship{}).Select("COALESCE(SUM(total_hours), 0)").Row().Scan(&stats.TotalMentorshipHours)
|
||||
h.db.Model(&models.MentorshipRequest{}).Where("status = ?", "pending").Count(&stats.PendingRequests)
|
||||
h.db.Model(&models.ChallengeParticipant{}).Where("status = ?", "completed").Count(&stats.CompletedChallenges)
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// Helper function to calculate match score (simplified version)
|
||||
func calculateMatchScore(request models.MentorshipRequest) float64 {
|
||||
// This is a simplified version - in production, you'd use more sophisticated matching
|
||||
// based on skills, experience, availability, preferences, etc.
|
||||
score := 0.5 // Base score
|
||||
|
||||
// Add points for detailed description
|
||||
if len(request.Description) > 100 {
|
||||
score += 0.1
|
||||
}
|
||||
|
||||
// Add points for clear goals
|
||||
if len(request.Goals) > 50 {
|
||||
score += 0.1
|
||||
}
|
||||
|
||||
// Add points for specified duration
|
||||
if request.Duration > 0 {
|
||||
score += 0.1
|
||||
}
|
||||
|
||||
// Add points for availability
|
||||
if len(request.Availability) > 20 {
|
||||
score += 0.1
|
||||
}
|
||||
|
||||
if score > 1.0 {
|
||||
score = 1.0
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// GetCourses handles GET /api/v1/courses
|
||||
func GetCourses(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
var courses []models.Course
|
||||
|
||||
// Parse query parameters
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
category := c.Query("category")
|
||||
level := c.Query("level")
|
||||
isZTM := c.Query("is_ztm")
|
||||
|
||||
// Validate pagination
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
offset := (page - 1) * limit
|
||||
|
||||
// Build query
|
||||
query := db.Where("is_active = ?", true)
|
||||
|
||||
if category != "" {
|
||||
query = query.Where("category = ?", category)
|
||||
}
|
||||
|
||||
if level != "" {
|
||||
query = query.Where("level = ?", level)
|
||||
}
|
||||
|
||||
if isZTM == "true" {
|
||||
query = query.Where("is_ztm_course = ?", true)
|
||||
}
|
||||
|
||||
// Get total count
|
||||
var total int64
|
||||
query.Model(&models.Course{}).Count(&total)
|
||||
|
||||
// Get courses with pagination
|
||||
if err := query.Order("is_featured DESC, rating DESC, students_count DESC").
|
||||
Offset(offset).Limit(limit).Find(&courses).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to fetch courses",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate pagination info
|
||||
totalPages := (total + int64(limit) - 1) / int64(limit)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"courses": courses,
|
||||
"pagination": gin.H{
|
||||
"current_page": page,
|
||||
"total_pages": totalPages,
|
||||
"total_count": total,
|
||||
"limit": limit,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetCourse handles GET /api/v1/courses/:id
|
||||
func GetCourse(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
id := c.Param("id")
|
||||
|
||||
var course models.Course
|
||||
if err := db.Where("id = ? AND is_active = ?", id, true).First(&course).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Course not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, course)
|
||||
}
|
||||
|
||||
// GetCourseBySlug handles GET /api/v1/courses/slug/:slug
|
||||
func GetCourseBySlug(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
slug := c.Param("slug")
|
||||
|
||||
var course models.Course
|
||||
if err := db.Where("slug = ? AND is_active = ?", slug, true).First(&course).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Course not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, course)
|
||||
}
|
||||
|
||||
// GetFeaturedCourses handles GET /api/v1/courses/featured
|
||||
func GetFeaturedCourses(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
var courses []models.Course
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
if limit < 1 || limit > 20 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
if err := db.Where("is_active = ? AND is_featured = ?", true, true).
|
||||
Order("rating DESC, students_count DESC").
|
||||
Limit(limit).Find(&courses).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to fetch featured courses",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"courses": courses,
|
||||
"count": len(courses),
|
||||
})
|
||||
}
|
||||
|
||||
// GetZTMCourses handles GET /api/v1/courses/ztm
|
||||
func GetZTMCourses(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
var courses []models.Course
|
||||
|
||||
// Parse query parameters
|
||||
category := c.Query("category")
|
||||
level := c.Query("level")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
if limit < 1 || limit > 50 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
// Build query
|
||||
query := db.Where("is_active = ? AND is_ztm_course = ?", true, true)
|
||||
|
||||
if category != "" {
|
||||
query = query.Where("category = ?", category)
|
||||
}
|
||||
|
||||
if level != "" {
|
||||
query = query.Where("level = ?", level)
|
||||
}
|
||||
|
||||
if err := query.Order("is_featured DESC, rating DESC, students_count DESC").
|
||||
Limit(limit).Find(&courses).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to fetch ZTM courses",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"courses": courses,
|
||||
"count": len(courses),
|
||||
})
|
||||
}
|
||||
|
||||
// GetCourseCategories handles GET /api/v1/courses/categories
|
||||
func GetCourseCategories(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
|
||||
var categories []struct {
|
||||
Category string `json:"category"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
if err := db.Model(&models.Course{}).
|
||||
Where("is_active = ?", true).
|
||||
Select("category, COUNT(*) as count").
|
||||
Group("category").
|
||||
Order("count DESC").
|
||||
Find(&categories).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to fetch course categories",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"categories": categories,
|
||||
})
|
||||
}
|
||||
|
||||
// SearchCourses handles POST /api/v1/courses/search
|
||||
func SearchCourses(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
|
||||
type SearchRequest struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
Category string `json:"category"`
|
||||
Level string `json:"level"`
|
||||
Limit int `json:"limit"`
|
||||
Page int `json:"page"`
|
||||
}
|
||||
|
||||
var req SearchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid request body",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if req.Limit <= 0 || req.Limit > 50 {
|
||||
req.Limit = 20
|
||||
}
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
|
||||
offset := (req.Page - 1) * req.Limit
|
||||
|
||||
// Build search query
|
||||
query := db.Where("is_active = ? AND (title ILIKE ? OR description ILIKE ? OR topics::text ILIKE ?)",
|
||||
true, "%"+req.Query+"%", "%"+req.Query+"%", "%"+req.Query+"%")
|
||||
|
||||
if req.Category != "" {
|
||||
query = query.Where("category = ?", req.Category)
|
||||
}
|
||||
|
||||
if req.Level != "" {
|
||||
query = query.Where("level = ?", req.Level)
|
||||
}
|
||||
|
||||
// Get total count
|
||||
var total int64
|
||||
query.Model(&models.Course{}).Count(&total)
|
||||
|
||||
// Get courses
|
||||
var courses []models.Course
|
||||
if err := query.Order("is_featured DESC, rating DESC, students_count DESC").
|
||||
Offset(offset).Limit(req.Limit).Find(&courses).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to search courses",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate pagination info
|
||||
totalPages := (total + int64(req.Limit) - 1) / int64(req.Limit)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"courses": courses,
|
||||
"query": req.Query,
|
||||
"pagination": gin.H{
|
||||
"current_page": req.Page,
|
||||
"total_pages": totalPages,
|
||||
"total_count": total,
|
||||
"limit": req.Limit,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetLearningPathCourses handles GET /api/v1/learning-paths/:id/courses
|
||||
func GetLearningPathCourses(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
learningPathID := c.Param("id")
|
||||
|
||||
var learningPathCourses []models.LearningPathCourse
|
||||
if err := db.Where("learning_path_id = ?", learningPathID).
|
||||
Preload("Course").
|
||||
Order("order ASC").
|
||||
Find(&learningPathCourses).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to fetch learning path courses",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract courses
|
||||
courses := make([]models.Course, 0)
|
||||
for _, lpc := range learningPathCourses {
|
||||
if lpc.Course.IsActive {
|
||||
courses = append(courses, lpc.Course)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"courses": courses,
|
||||
"count": len(courses),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/middleware"
|
||||
)
|
||||
|
||||
// DemoStatus returns the current demo mode status
|
||||
func DemoStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"demoMode": middleware.IsDemoMode(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
"github.com/trackeep/backend/utils"
|
||||
)
|
||||
|
||||
// EncryptionRequest represents a request to encrypt content
|
||||
type EncryptionRequest struct {
|
||||
Content string `json:"content" binding:"required"`
|
||||
EncryptTitle bool `json:"encrypt_title"`
|
||||
}
|
||||
|
||||
// EncryptionResponse represents a response with encrypted content
|
||||
type EncryptionResponse struct {
|
||||
EncryptedContent string `json:"encrypted_content"`
|
||||
IsEncrypted bool `json:"is_encrypted"`
|
||||
}
|
||||
|
||||
// EncryptNoteContent encrypts note content
|
||||
func EncryptNoteContent(c *gin.Context) {
|
||||
var req EncryptionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Encrypt the content
|
||||
encryptedContent, err := utils.Encrypt(req.Content)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to encrypt content"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, EncryptionResponse{
|
||||
EncryptedContent: encryptedContent,
|
||||
IsEncrypted: true,
|
||||
})
|
||||
}
|
||||
|
||||
// DecryptNoteContent decrypts note content
|
||||
func DecryptNoteContent(c *gin.Context) {
|
||||
var req struct {
|
||||
EncryptedContent string `json:"encrypted_content" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt the content
|
||||
decryptedContent, err := utils.Decrypt(req.EncryptedContent)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to decrypt content"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"decrypted_content": decryptedContent,
|
||||
"is_encrypted": false,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateEncryptedNote creates a new encrypted note
|
||||
func CreateEncryptedNote(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := user.(models.User)
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags"`
|
||||
ContentType string `json:"content_type"`
|
||||
EncryptTitle bool `json:"encrypt_title"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
|
||||
// Encrypt content
|
||||
encryptedContent, err := utils.Encrypt(req.Content)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to encrypt content"})
|
||||
return
|
||||
}
|
||||
|
||||
// Encrypt title if requested
|
||||
var encryptedTitle string
|
||||
var titleToStore string
|
||||
if req.EncryptTitle {
|
||||
encryptedTitle, err = utils.Encrypt(req.Title)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to encrypt title"})
|
||||
return
|
||||
}
|
||||
titleToStore = encryptedTitle
|
||||
} else {
|
||||
titleToStore = req.Title
|
||||
}
|
||||
|
||||
// Create note
|
||||
note := models.Note{
|
||||
UserID: currentUser.ID,
|
||||
Title: titleToStore,
|
||||
Content: encryptedContent,
|
||||
Description: req.Description,
|
||||
ContentType: req.ContentType,
|
||||
IsEncrypted: true,
|
||||
IsPublic: false, // Encrypted notes are private by default
|
||||
}
|
||||
|
||||
if err := db.Create(¬e).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to create note"})
|
||||
return
|
||||
}
|
||||
|
||||
// Handle tags if provided
|
||||
if len(req.Tags) > 0 {
|
||||
var tags []models.Tag
|
||||
for _, tagName := range req.Tags {
|
||||
var tag models.Tag
|
||||
if err := db.Where("name = ?", tagName).First(&tag).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
tag = models.Tag{Name: tagName}
|
||||
db.Create(&tag)
|
||||
}
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
db.Model(¬e).Association("Tags").Append(tags)
|
||||
}
|
||||
|
||||
// Return note without encrypted content for security
|
||||
responseNote := note
|
||||
responseNote.Content = "[ENCRYPTED]"
|
||||
if req.EncryptTitle {
|
||||
responseNote.Title = "[ENCRYPTED]"
|
||||
}
|
||||
|
||||
c.JSON(201, gin.H{"note": responseNote})
|
||||
}
|
||||
|
||||
// GetEncryptedNote retrieves and decrypts a note
|
||||
func GetEncryptedNote(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := user.(models.User)
|
||||
noteID := c.Param("id")
|
||||
|
||||
db := config.GetDB()
|
||||
|
||||
var note models.Note
|
||||
if err := db.Where("id = ? AND user_id = ?", noteID, currentUser.ID).First(¬e).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(404, gin.H{"error": "Note not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(500, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
// If note is encrypted, decrypt it
|
||||
if note.IsEncrypted {
|
||||
decryptedContent, err := utils.Decrypt(note.Content)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to decrypt note content"})
|
||||
return
|
||||
}
|
||||
note.Content = decryptedContent
|
||||
|
||||
// Check if title is also encrypted (simple heuristic)
|
||||
if note.Title != "[ENCRYPTED]" && utils.IsEncrypted(note.Title) {
|
||||
decryptedTitle, err := utils.Decrypt(note.Title)
|
||||
if err == nil {
|
||||
note.Title = decryptedTitle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"note": note})
|
||||
}
|
||||
|
||||
// UploadEncryptedFile uploads and encrypts a file
|
||||
func UploadEncryptedFile(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := user.(models.User)
|
||||
|
||||
// Parse multipart form
|
||||
err := c.Request.ParseMultipartForm(32 << 20) // 32MB max
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "Failed to parse form"})
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "No file provided"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
description := c.PostForm("description")
|
||||
tagsStr := c.PostForm("tags")
|
||||
isPublicStr := c.PostForm("is_public")
|
||||
|
||||
// Parse tags
|
||||
var tags []string
|
||||
if tagsStr != "" {
|
||||
tags = strings.Split(tagsStr, ",")
|
||||
for i, tag := range tags {
|
||||
tags[i] = strings.TrimSpace(tag)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse is_public
|
||||
isPublic := false
|
||||
if isPublicStr != "" {
|
||||
isPublic, _ = strconv.ParseBool(isPublicStr)
|
||||
}
|
||||
|
||||
// Read file content
|
||||
fileContent, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to read file"})
|
||||
return
|
||||
}
|
||||
|
||||
// Encrypt file content
|
||||
encryptedContent, err := utils.EncryptFile(fileContent)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to encrypt file"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
originalName := header.Filename
|
||||
fileName := fmt.Sprintf("%d_%s", currentUser.ID, generateRandomStringForFile(16))
|
||||
filePath := filepath.Join("uploads", fileName)
|
||||
|
||||
// Save encrypted file to disk
|
||||
if err := os.WriteFile(filePath, encryptedContent, 0644); err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to save encrypted file"})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine file type
|
||||
fileType := determineFileTypeForEncryption(header.Filename, header.Header.Get("Content-Type"))
|
||||
|
||||
// Create file record
|
||||
db := config.GetDB()
|
||||
fileRecord := models.File{
|
||||
UserID: currentUser.ID,
|
||||
OriginalName: originalName,
|
||||
FileName: fileName,
|
||||
FilePath: filePath,
|
||||
FileSize: int64(len(encryptedContent)),
|
||||
MimeType: header.Header.Get("Content-Type"),
|
||||
FileType: fileType,
|
||||
Description: description,
|
||||
IsPublic: isPublic,
|
||||
IsEncrypted: true,
|
||||
}
|
||||
|
||||
if err := db.Create(&fileRecord).Error; err != nil {
|
||||
// Clean up file if database insert fails
|
||||
os.Remove(filePath)
|
||||
c.JSON(500, gin.H{"error": "Failed to create file record"})
|
||||
return
|
||||
}
|
||||
|
||||
// Handle tags if provided
|
||||
if len(tags) > 0 {
|
||||
var tagModels []models.Tag
|
||||
for _, tagName := range tags {
|
||||
var tag models.Tag
|
||||
if err := db.Where("name = ?", tagName).First(&tag).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
tag = models.Tag{Name: tagName}
|
||||
db.Create(&tag)
|
||||
}
|
||||
}
|
||||
tagModels = append(tagModels, tag)
|
||||
}
|
||||
db.Model(&fileRecord).Association("Tags").Append(tagModels)
|
||||
}
|
||||
|
||||
c.JSON(201, gin.H{"file": fileRecord})
|
||||
}
|
||||
|
||||
// DownloadEncryptedFile downloads and decrypts a file
|
||||
func DownloadEncryptedFile(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := user.(models.User)
|
||||
fileID := c.Param("id")
|
||||
|
||||
db := config.GetDB()
|
||||
|
||||
var fileRecord models.File
|
||||
if err := db.Where("id = ? AND user_id = ?", fileID, currentUser.ID).First(&fileRecord).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(404, gin.H{"error": "File not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(500, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Read encrypted file
|
||||
encryptedContent, err := os.ReadFile(fileRecord.FilePath)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to read file"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt file content
|
||||
var fileContent []byte
|
||||
if fileRecord.IsEncrypted {
|
||||
fileContent, err = utils.DecryptFile(encryptedContent)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to decrypt file"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
fileContent = encryptedContent
|
||||
}
|
||||
|
||||
// Set headers for file download
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileRecord.OriginalName))
|
||||
c.Header("Content-Type", fileRecord.MimeType)
|
||||
c.Data(200, fileRecord.MimeType, fileContent)
|
||||
}
|
||||
|
||||
// GetEncryptionStatus returns encryption status and statistics
|
||||
func GetEncryptionStatus(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := user.(models.User)
|
||||
db := config.GetDB()
|
||||
|
||||
// Count encrypted vs unencrypted notes
|
||||
var encryptedNotesCount, totalNotesCount int64
|
||||
db.Model(&models.Note{}).Where("user_id = ?", currentUser.ID).Count(&totalNotesCount)
|
||||
db.Model(&models.Note{}).Where("user_id = ? AND is_encrypted = ?", currentUser.ID, true).Count(&encryptedNotesCount)
|
||||
|
||||
// Count encrypted vs unencrypted files
|
||||
var encryptedFilesCount, totalFilesCount int64
|
||||
db.Model(&models.File{}).Where("user_id = ?", currentUser.ID).Count(&totalFilesCount)
|
||||
db.Model(&models.File{}).Where("user_id = ? AND is_encrypted = ?", currentUser.ID, true).Count(&encryptedFilesCount)
|
||||
|
||||
status := gin.H{
|
||||
"notes": gin.H{
|
||||
"total": totalNotesCount,
|
||||
"encrypted": encryptedNotesCount,
|
||||
"percentage": float64(encryptedNotesCount) / float64(totalNotesCount) * 100,
|
||||
},
|
||||
"files": gin.H{
|
||||
"total": totalFilesCount,
|
||||
"encrypted": encryptedFilesCount,
|
||||
"percentage": float64(encryptedFilesCount) / float64(totalFilesCount) * 100,
|
||||
},
|
||||
"encryption_enabled": true,
|
||||
}
|
||||
|
||||
c.JSON(200, status)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func generateRandomStringForFile(length int) string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
b[i] = charset[i%len(charset)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func determineFileTypeForEncryption(filename, mimeType string) models.FileType {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
|
||||
switch {
|
||||
case strings.Contains(mimeType, "image/") || ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" || ext == ".webp":
|
||||
return models.FileTypeImage
|
||||
case strings.Contains(mimeType, "video/") || ext == ".mp4" || ext == ".avi" || ext == ".mov" || ext == ".mkv":
|
||||
return models.FileTypeVideo
|
||||
case strings.Contains(mimeType, "audio/") || ext == ".mp3" || ext == ".wav" || ext == ".flac" || ext == ".ogg":
|
||||
return models.FileTypeAudio
|
||||
case ext == ".zip" || ext == ".rar" || ext == ".7z" || ext == ".tar" || ext == ".gz":
|
||||
return models.FileTypeArchive
|
||||
case strings.Contains(mimeType, "text/") || ext == ".pdf" || ext == ".doc" || ext == ".docx" || ext == ".txt" || ext == ".md":
|
||||
return models.FileTypeDocument
|
||||
default:
|
||||
return models.FileTypeOther
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/oauth2"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// GitHub OAuth configuration
|
||||
var githubOAuthConfig *oauth2.Config
|
||||
|
||||
func initGitHubOAuth() {
|
||||
githubOAuthConfig = &oauth2.Config{
|
||||
ClientID: os.Getenv("GITHUB_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
|
||||
RedirectURL: os.Getenv("GITHUB_REDIRECT_URL"),
|
||||
Scopes: []string{"user:email", "repo"},
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: "https://github.com/login/oauth/authorize",
|
||||
TokenURL: "https://github.com/login/oauth/access_token",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GitHubUser represents the GitHub user profile
|
||||
type GitHubUser struct {
|
||||
ID int `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// GitHubRepo represents a GitHub repository
|
||||
type GitHubRepo struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Stargazers int `json:"stargazers_count"`
|
||||
Forks int `json:"forks_count"`
|
||||
Watchers int `json:"watchers_count"`
|
||||
Language string `json:"language"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Size int `json:"size"`
|
||||
OpenIssues int `json:"open_issues_count"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
}
|
||||
|
||||
// GitHubLogin initiates the GitHub OAuth flow
|
||||
func GitHubLogin(c *gin.Context) {
|
||||
if githubOAuthConfig == nil {
|
||||
initGitHubOAuth()
|
||||
}
|
||||
|
||||
// Generate state parameter to prevent CSRF
|
||||
state := generateRandomString(32)
|
||||
|
||||
// Store state in session or cookie (simplified here)
|
||||
c.SetCookie("oauth_state", state, 3600, "/", "", false, true)
|
||||
|
||||
// Redirect to GitHub for authorization
|
||||
authURL := githubOAuthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
|
||||
c.Redirect(http.StatusTemporaryRedirect, authURL)
|
||||
}
|
||||
|
||||
// GitHubCallback handles the GitHub OAuth callback
|
||||
func GitHubCallback(c *gin.Context) {
|
||||
if githubOAuthConfig == nil {
|
||||
initGitHubOAuth()
|
||||
}
|
||||
|
||||
// Verify state parameter
|
||||
storedState, err := c.Cookie("oauth_state")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "State not found"})
|
||||
return
|
||||
}
|
||||
|
||||
state := c.Query("state")
|
||||
if state != storedState {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid state"})
|
||||
return
|
||||
}
|
||||
|
||||
// Clear the state cookie
|
||||
c.SetCookie("oauth_state", "", -1, "/", "", false, true)
|
||||
|
||||
// Exchange authorization code for access token
|
||||
code := c.Query("code")
|
||||
token, err := githubOAuthConfig.Exchange(context.Background(), code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user info from GitHub
|
||||
user, err := getGitHubUser(token.AccessToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get or create user in database
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
var existingUser models.User
|
||||
|
||||
// First try to find by GitHub ID
|
||||
err = db.Where("github_id = ?", user.ID).First(&existingUser).Error
|
||||
if err != nil {
|
||||
// If not found by GitHub ID, try by email
|
||||
err = db.Where("email = ?", user.Email).First(&existingUser).Error
|
||||
if err != nil {
|
||||
// Create new user
|
||||
newUser := models.User{
|
||||
Username: user.Login,
|
||||
Email: user.Email,
|
||||
FullName: user.Name,
|
||||
GitHubID: user.ID,
|
||||
AvatarURL: user.AvatarURL,
|
||||
Provider: "github",
|
||||
}
|
||||
|
||||
if err := db.Create(&newUser).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
||||
return
|
||||
}
|
||||
existingUser = newUser
|
||||
} else {
|
||||
// Update existing user with GitHub info
|
||||
existingUser.GitHubID = user.ID
|
||||
existingUser.AvatarURL = user.AvatarURL
|
||||
existingUser.Provider = "github"
|
||||
db.Save(&existingUser)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"user_id": existingUser.ID,
|
||||
"email": existingUser.Email,
|
||||
"username": existingUser.Username,
|
||||
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
|
||||
})
|
||||
|
||||
tokenString, err := jwtToken.SignedString([]byte(config.JWTSecret))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to frontend with token
|
||||
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", os.Getenv("FRONTEND_URL"), tokenString)
|
||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||
}
|
||||
|
||||
// getGitHubUser fetches user information from GitHub API
|
||||
func getGitHubUser(accessToken string) (*GitHubUser, error) {
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("GET", "https://api.github.com/user", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user GitHubUser
|
||||
if err := json.Unmarshal(body, &user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If email is not public, fetch user emails
|
||||
if user.Email == "" {
|
||||
email, err := getPrimaryEmail(accessToken)
|
||||
if err == nil {
|
||||
user.Email = email
|
||||
}
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// getPrimaryEmail fetches the primary email for the user
|
||||
func getPrimaryEmail(accessToken string) (string, error) {
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("GET", "https://api.github.com/user/emails", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var emails []struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &emails); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, email := range emails {
|
||||
if email.Primary && email.Verified {
|
||||
return email.Email, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no primary verified email found")
|
||||
}
|
||||
|
||||
// HandleOAuthCallback handles the callback from the centralized OAuth service
|
||||
func HandleOAuthCallback(c *gin.Context) {
|
||||
// Get the token from the query parameters
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No token provided"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the JWT from the OAuth service
|
||||
claims := jwt.MapClaims{}
|
||||
parsedToken, err := jwt.ParseWithClaims(token, &claims, func(token *jwt.Token) (interface{}, error) {
|
||||
// Use the OAuth service's JWT secret (should be shared)
|
||||
return []byte(os.Getenv("OAUTH_JWT_SECRET")), nil
|
||||
})
|
||||
|
||||
if err != nil || !parsedToken.Valid {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid OAuth token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract user information from OAuth service
|
||||
username, _ := claims["username"].(string)
|
||||
email, _ := claims["email"].(string)
|
||||
githubID, _ := claims["github_id"]
|
||||
accessToken, _ := claims["access_token"].(string)
|
||||
|
||||
// Get database
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
|
||||
// Find or create user in local database
|
||||
var user models.User
|
||||
err = db.Where("email = ?", email).First(&user).Error
|
||||
if err != nil {
|
||||
// Create new user
|
||||
newUser := models.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
GitHubID: int(githubID.(float64)), // JWT numbers are float64
|
||||
Provider: "github",
|
||||
}
|
||||
|
||||
if err := db.Create(&newUser).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
||||
return
|
||||
}
|
||||
user = newUser
|
||||
} else {
|
||||
// Update existing user with GitHub info
|
||||
user.GitHubID = int(githubID.(float64))
|
||||
user.Provider = "github"
|
||||
db.Save(&user)
|
||||
}
|
||||
|
||||
// Generate Trackeep JWT token
|
||||
trackeepToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"user_id": user.ID,
|
||||
"email": user.Email,
|
||||
"username": user.Username,
|
||||
"github_id": user.GitHubID,
|
||||
"access_token": accessToken, // Pass through the GitHub access token
|
||||
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
|
||||
})
|
||||
|
||||
trackeepTokenString, err := trackeepToken.SignedString([]byte(os.Getenv("JWT_SECRET")))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to frontend with Trackeep token
|
||||
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", os.Getenv("FRONTEND_URL"), trackeepTokenString)
|
||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||
}
|
||||
|
||||
// GetCurrentUser returns the current authenticated user with GitHub info
|
||||
func GetCurrentUserWithGitHub(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := user.(models.User)
|
||||
|
||||
// Remove sensitive data
|
||||
currentUser.Password = ""
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"user": currentUser})
|
||||
}
|
||||
func GetGitHubRepos(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.GitHubID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub not connected"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the JWT token from the request header
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "No authorization header"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract token from "Bearer <token>"
|
||||
tokenString := authHeader
|
||||
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||
tokenString = authHeader[7:]
|
||||
}
|
||||
|
||||
// Parse the JWT to get the GitHub access token from the centralized OAuth service
|
||||
claims := jwt.MapClaims{}
|
||||
token, err := jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(os.Getenv("JWT_SECRET")), nil
|
||||
})
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract GitHub access token from the OAuth service JWT
|
||||
githubAccessToken, ok := claims["access_token"]
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub access token not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch repositories using the GitHub access token
|
||||
repos, err := fetchGitHubRepos(githubAccessToken.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch repos: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"repos": repos})
|
||||
}
|
||||
|
||||
// fetchGitHubRepos fetches repositories from GitHub API
|
||||
func fetchGitHubRepos(accessToken string) ([]GitHubRepo, error) {
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("GET", "https://api.github.com/user/repos?type=owner&sort=updated&per_page=100", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var repos []GitHubRepo
|
||||
if err := json.Unmarshal(body, &repos); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
// generateRandomString generates a random string for state parameter
|
||||
func generateRandomString(length int) string {
|
||||
bytes := make([]byte, length)
|
||||
rand.Read(bytes)
|
||||
return hex.EncodeToString(bytes)
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GoalsHabitsHandler handles goals and habits operations
|
||||
type GoalsHabitsHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewGoalsHabitsHandler creates a new goals and habits handler
|
||||
func NewGoalsHabitsHandler(db *gorm.DB) *GoalsHabitsHandler {
|
||||
return &GoalsHabitsHandler{db: db}
|
||||
}
|
||||
|
||||
// Goal Handlers
|
||||
|
||||
// CreateGoal creates a new goal
|
||||
func (h *GoalsHabitsHandler) CreateGoal(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
TargetValue float64 `json:"target_value"`
|
||||
Unit string `json:"unit"`
|
||||
Deadline time.Time `json:"deadline"`
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
goal := models.Goal{
|
||||
UserID: userID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Category: req.Category,
|
||||
TargetValue: req.TargetValue,
|
||||
CurrentValue: 0,
|
||||
Unit: req.Unit,
|
||||
Deadline: req.Deadline,
|
||||
Status: "active",
|
||||
Priority: req.Priority,
|
||||
Progress: 0,
|
||||
IsCompleted: false,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&goal).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create goal"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, goal)
|
||||
}
|
||||
|
||||
// GetGoals retrieves user's goals
|
||||
func (h *GoalsHabitsHandler) GetGoals(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
category := c.Query("category")
|
||||
status := c.Query("status")
|
||||
priority := c.Query("priority")
|
||||
limit := 20
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
query := h.db.Where("user_id = ?", userID)
|
||||
if category != "" {
|
||||
query = query.Where("category = ?", category)
|
||||
}
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
if priority != "" {
|
||||
query = query.Where("priority = ?", priority)
|
||||
}
|
||||
|
||||
var goals []models.Goal
|
||||
if err := query.Preload("Milestones").Order("created_at DESC").Limit(limit).Find(&goals).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch goals"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"goals": goals,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// GetGoal retrieves a specific goal
|
||||
func (h *GoalsHabitsHandler) GetGoal(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
goalID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid goal ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var goal models.Goal
|
||||
if err := h.db.Where("id = ? AND user_id = ?", goalID, userID).
|
||||
Preload("Milestones").
|
||||
First(&goal).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Goal not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, goal)
|
||||
}
|
||||
|
||||
// UpdateGoal updates a goal
|
||||
func (h *GoalsHabitsHandler) UpdateGoal(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
goalID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid goal ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var goal models.Goal
|
||||
if err := h.db.Where("id = ? AND user_id = ?", goalID, userID).First(&goal).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Goal not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
TargetValue float64 `json:"target_value"`
|
||||
CurrentValue float64 `json:"current_value"`
|
||||
Unit string `json:"unit"`
|
||||
Deadline time.Time `json:"deadline"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if req.Title != "" {
|
||||
goal.Title = req.Title
|
||||
}
|
||||
if req.Description != "" {
|
||||
goal.Description = req.Description
|
||||
}
|
||||
if req.Category != "" {
|
||||
goal.Category = req.Category
|
||||
}
|
||||
if req.TargetValue > 0 {
|
||||
goal.TargetValue = req.TargetValue
|
||||
}
|
||||
if req.CurrentValue >= 0 {
|
||||
goal.CurrentValue = req.CurrentValue
|
||||
}
|
||||
if req.Unit != "" {
|
||||
goal.Unit = req.Unit
|
||||
}
|
||||
if !req.Deadline.IsZero() {
|
||||
goal.Deadline = req.Deadline
|
||||
}
|
||||
if req.Status != "" {
|
||||
goal.Status = req.Status
|
||||
}
|
||||
if req.Priority != "" {
|
||||
goal.Priority = req.Priority
|
||||
}
|
||||
|
||||
if err := h.db.Save(&goal).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update goal"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, goal)
|
||||
}
|
||||
|
||||
// DeleteGoal deletes a goal
|
||||
func (h *GoalsHabitsHandler) DeleteGoal(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
goalID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid goal ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var goal models.Goal
|
||||
if err := h.db.Where("id = ? AND user_id = ?", goalID, userID).First(&goal).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Goal not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Delete(&goal).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete goal"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Goal deleted successfully"})
|
||||
}
|
||||
|
||||
// Habit Handlers
|
||||
|
||||
// CreateHabit creates a new habit
|
||||
func (h *GoalsHabitsHandler) CreateHabit(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
TargetFrequency int `json:"target_frequency"`
|
||||
FrequencyUnit string `json:"frequency_unit"`
|
||||
TargetValue float64 `json:"target_value"`
|
||||
Unit string `json:"unit"`
|
||||
TimeOfDay string `json:"time_of_day"`
|
||||
DaysOfWeek []string `json:"days_of_week"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
GoalID *uint `json:"goal_id"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
habit := models.Habit{
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Category: req.Category,
|
||||
TargetFrequency: req.TargetFrequency,
|
||||
FrequencyUnit: req.FrequencyUnit,
|
||||
TargetValue: req.TargetValue,
|
||||
Unit: req.Unit,
|
||||
TimeOfDay: req.TimeOfDay,
|
||||
DaysOfWeek: req.DaysOfWeek,
|
||||
IsActive: req.IsActive,
|
||||
IsPublic: req.IsPublic,
|
||||
GoalID: req.GoalID,
|
||||
Streak: 0,
|
||||
LongestStreak: 0,
|
||||
CompletionRate: 0,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&habit).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create habit"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, habit)
|
||||
}
|
||||
|
||||
// GetHabits retrieves user's habits
|
||||
func (h *GoalsHabitsHandler) GetHabits(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
category := c.Query("category")
|
||||
isActive := c.Query("is_active")
|
||||
limit := 20
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
query := h.db.Where("user_id = ?", userID)
|
||||
if category != "" {
|
||||
query = query.Where("category = ?", category)
|
||||
}
|
||||
if isActive != "" {
|
||||
active := isActive == "true"
|
||||
query = query.Where("is_active = ?", active)
|
||||
}
|
||||
|
||||
var habits []models.Habit
|
||||
if err := query.Preload("Goal").Order("created_at DESC").Limit(limit).Find(&habits).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch habits"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"habits": habits,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// GetHabit retrieves a specific habit
|
||||
func (h *GoalsHabitsHandler) GetHabit(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
habitID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid habit ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var habit models.Habit
|
||||
if err := h.db.Where("id = ? AND user_id = ?", habitID, userID).
|
||||
Preload("Goal").
|
||||
Preload("HabitEntries", "entry_date >= ?", time.Now().AddDate(0, 0, -30)).
|
||||
First(&habit).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Habit not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, habit)
|
||||
}
|
||||
|
||||
// UpdateHabit updates a habit
|
||||
func (h *GoalsHabitsHandler) UpdateHabit(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
habitID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid habit ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var habit models.Habit
|
||||
if err := h.db.Where("id = ? AND user_id = ?", habitID, userID).First(&habit).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Habit not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
TargetFrequency int `json:"target_frequency"`
|
||||
FrequencyUnit string `json:"frequency_unit"`
|
||||
TargetValue float64 `json:"target_value"`
|
||||
Unit string `json:"unit"`
|
||||
TimeOfDay string `json:"time_of_day"`
|
||||
DaysOfWeek []string `json:"days_of_week"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if req.Name != "" {
|
||||
habit.Name = req.Name
|
||||
}
|
||||
if req.Description != "" {
|
||||
habit.Description = req.Description
|
||||
}
|
||||
if req.Category != "" {
|
||||
habit.Category = req.Category
|
||||
}
|
||||
if req.TargetFrequency > 0 {
|
||||
habit.TargetFrequency = req.TargetFrequency
|
||||
}
|
||||
if req.FrequencyUnit != "" {
|
||||
habit.FrequencyUnit = req.FrequencyUnit
|
||||
}
|
||||
if req.TargetValue > 0 {
|
||||
habit.TargetValue = req.TargetValue
|
||||
}
|
||||
if req.Unit != "" {
|
||||
habit.Unit = req.Unit
|
||||
}
|
||||
if req.TimeOfDay != "" {
|
||||
habit.TimeOfDay = req.TimeOfDay
|
||||
}
|
||||
if req.DaysOfWeek != nil {
|
||||
habit.DaysOfWeek = req.DaysOfWeek
|
||||
}
|
||||
habit.IsActive = req.IsActive
|
||||
habit.IsPublic = req.IsPublic
|
||||
|
||||
if err := h.db.Save(&habit).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update habit"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, habit)
|
||||
}
|
||||
|
||||
// DeleteHabit deletes a habit
|
||||
func (h *GoalsHabitsHandler) DeleteHabit(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
habitID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid habit ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var habit models.Habit
|
||||
if err := h.db.Where("id = ? AND user_id = ?", habitID, userID).First(&habit).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Habit not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Delete(&habit).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete habit"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Habit deleted successfully"})
|
||||
}
|
||||
|
||||
// HabitEntry Handlers
|
||||
|
||||
// CreateHabitEntry creates a new habit entry
|
||||
func (h *GoalsHabitsHandler) CreateHabitEntry(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req struct {
|
||||
HabitID uint `json:"habit_id" binding:"required"`
|
||||
EntryDate time.Time `json:"entry_date" binding:"required"`
|
||||
Value float64 `json:"value"`
|
||||
TargetValue float64 `json:"target_value"`
|
||||
Unit string `json:"unit"`
|
||||
Notes string `json:"notes"`
|
||||
Quality int `json:"quality"`
|
||||
TimeSpent int `json:"time_spent"`
|
||||
Location string `json:"location"`
|
||||
Mood string `json:"mood"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify habit ownership
|
||||
var habit models.Habit
|
||||
if err := h.db.Where("id = ? AND user_id = ?", req.HabitID, userID).First(&habit).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Habit not found"})
|
||||
return
|
||||
}
|
||||
|
||||
entry := models.HabitEntry{
|
||||
HabitID: req.HabitID,
|
||||
EntryDate: req.EntryDate,
|
||||
Value: req.Value,
|
||||
TargetValue: req.TargetValue,
|
||||
Unit: req.Unit,
|
||||
Notes: req.Notes,
|
||||
Quality: req.Quality,
|
||||
TimeSpent: req.TimeSpent,
|
||||
Location: req.Location,
|
||||
Mood: req.Mood,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&entry).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create habit entry"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, entry)
|
||||
}
|
||||
|
||||
// GetHabitEntries retrieves habit entries
|
||||
func (h *GoalsHabitsHandler) GetHabitEntries(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
habitID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid habit ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify habit ownership
|
||||
var habit models.Habit
|
||||
if err := h.db.Where("id = ? AND user_id = ?", habitID, userID).First(&habit).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Habit not found"})
|
||||
return
|
||||
}
|
||||
|
||||
startDate := c.Query("start_date")
|
||||
endDate := c.Query("end_date")
|
||||
limit := 50
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
query := h.db.Where("habit_id = ?", habitID)
|
||||
if startDate != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", startDate); err == nil {
|
||||
query = query.Where("entry_date >= ?", parsed)
|
||||
}
|
||||
}
|
||||
if endDate != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", endDate); err == nil {
|
||||
query = query.Where("entry_date <= ?", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
var entries []models.HabitEntry
|
||||
if err := query.Order("entry_date DESC").Limit(limit).Find(&entries).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch habit entries"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"entries": entries,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// GetDashboardStats retrieves dashboard statistics for goals and habits
|
||||
func (h *GoalsHabitsHandler) GetDashboardStats(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
// Goal stats
|
||||
var totalGoals, activeGoals, completedGoals int64
|
||||
h.db.Model(&models.Goal{}).Where("user_id = ?", userID).Count(&totalGoals)
|
||||
h.db.Model(&models.Goal{}).Where("user_id = ? AND status = ?", userID, "active").Count(&activeGoals)
|
||||
h.db.Model(&models.Goal{}).Where("user_id = ? AND status = ?", userID, "completed").Count(&completedGoals)
|
||||
|
||||
// Habit stats
|
||||
var totalHabits, activeHabits int64
|
||||
h.db.Model(&models.Habit{}).Where("user_id = ?", userID).Count(&totalHabits)
|
||||
h.db.Model(&models.Habit{}).Where("user_id = ? AND is_active = ?", userID, true).Count(&activeHabits)
|
||||
|
||||
// Today's habit entries
|
||||
today := time.Now().Truncate(24 * time.Hour)
|
||||
tomorrow := today.Add(24 * time.Hour)
|
||||
var todayEntries int64
|
||||
h.db.Model(&models.HabitEntry{}).
|
||||
Joins("JOIN habits ON habit_entries.habit_id = habits.id").
|
||||
Where("habits.user_id = ? AND habit_entries.entry_date >= ? AND habit_entries.entry_date < ?", userID, today, tomorrow).
|
||||
Count(&todayEntries)
|
||||
|
||||
// Current week streak
|
||||
weekAgo := time.Now().AddDate(0, 0, -7)
|
||||
var weekEntries int64
|
||||
h.db.Model(&models.HabitEntry{}).
|
||||
Joins("JOIN habits ON habit_entries.habit_id = habits.id").
|
||||
Where("habits.user_id = ? AND habit_entries.entry_date >= ? AND habit_entries.is_completed = ?", userID, weekAgo, true).
|
||||
Count(&weekEntries)
|
||||
|
||||
stats := gin.H{
|
||||
"goals": gin.H{
|
||||
"total": totalGoals,
|
||||
"active": activeGoals,
|
||||
"completed": completedGoals,
|
||||
},
|
||||
"habits": gin.H{
|
||||
"total": totalHabits,
|
||||
"active": activeHabits,
|
||||
"today_entries": todayEntries,
|
||||
"week_streak": weekEntries,
|
||||
},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// IntegrationHandler handles integration-related requests
|
||||
type IntegrationHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewIntegrationHandler creates a new integration handler
|
||||
func NewIntegrationHandler(db *gorm.DB) *IntegrationHandler {
|
||||
return &IntegrationHandler{db: db}
|
||||
}
|
||||
|
||||
// GetIntegrations returns all integrations for the current user
|
||||
func (h *IntegrationHandler) GetIntegrations(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
|
||||
var integrations []models.Integration
|
||||
if err := h.db.Where("user_id = ?", userID).
|
||||
Preload("SyncLogs", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("created_at DESC").Limit(10)
|
||||
}).
|
||||
Find(&integrations).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch integrations"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"integrations": integrations})
|
||||
}
|
||||
|
||||
// GetIntegration returns a specific integration
|
||||
func (h *IntegrationHandler) GetIntegration(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
integrationID := c.Param("id")
|
||||
|
||||
var integration models.Integration
|
||||
if err := h.db.Where("id = ? AND user_id = ?", integrationID, userID).
|
||||
Preload("SyncLogs", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("created_at DESC").Limit(50)
|
||||
}).
|
||||
First(&integration).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Integration not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch integration"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"integration": integration})
|
||||
}
|
||||
|
||||
// CreateIntegration creates a new integration
|
||||
func (h *IntegrationHandler) CreateIntegration(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
|
||||
var req struct {
|
||||
Type models.IntegrationType `json:"type" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Config models.IntegrationConfig `json:"config"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
integration := models.Integration{
|
||||
UserID: userID,
|
||||
Type: req.Type,
|
||||
Status: models.StatusPending,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Config: req.Config,
|
||||
SyncEnabled: true,
|
||||
SyncInterval: 60, // Default 1 hour
|
||||
}
|
||||
|
||||
if err := h.db.Create(&integration).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create integration"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"integration": integration})
|
||||
}
|
||||
|
||||
// UpdateIntegration updates an existing integration
|
||||
func (h *IntegrationHandler) UpdateIntegration(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
integrationID := c.Param("id")
|
||||
|
||||
var integration models.Integration
|
||||
if err := h.db.Where("id = ? AND user_id = ?", integrationID, userID).First(&integration).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Integration not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch integration"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Config *models.IntegrationConfig `json:"config"`
|
||||
SyncEnabled *bool `json:"syncEnabled"`
|
||||
SyncInterval *int `json:"syncInterval"`
|
||||
WebhookURL *string `json:"webhookUrl"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update fields if provided
|
||||
if req.Name != nil {
|
||||
integration.Name = *req.Name
|
||||
}
|
||||
if req.Description != nil {
|
||||
integration.Description = *req.Description
|
||||
}
|
||||
if req.Config != nil {
|
||||
integration.Config = *req.Config
|
||||
}
|
||||
if req.SyncEnabled != nil {
|
||||
integration.SyncEnabled = *req.SyncEnabled
|
||||
}
|
||||
if req.SyncInterval != nil {
|
||||
integration.SyncInterval = *req.SyncInterval
|
||||
}
|
||||
if req.WebhookURL != nil {
|
||||
integration.WebhookURL = *req.WebhookURL
|
||||
}
|
||||
|
||||
if err := h.db.Save(&integration).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update integration"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"integration": integration})
|
||||
}
|
||||
|
||||
// DeleteIntegration deletes an integration
|
||||
func (h *IntegrationHandler) DeleteIntegration(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
integrationID := c.Param("id")
|
||||
|
||||
if err := h.db.Where("id = ? AND user_id = ?", integrationID, userID).Delete(&models.Integration{}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete integration"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Integration deleted successfully"})
|
||||
}
|
||||
|
||||
// AuthorizeIntegration starts the OAuth flow for an integration
|
||||
func (h *IntegrationHandler) AuthorizeIntegration(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
integrationID := c.Param("id")
|
||||
|
||||
var integration models.Integration
|
||||
if err := h.db.Where("id = ? AND user_id = ?", integrationID, userID).First(&integration).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Integration not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch integration"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate authorization URL based on integration type
|
||||
var authURL string
|
||||
switch integration.Type {
|
||||
case models.IntegrationSlack:
|
||||
authURL = h.getSlackAuthURL(integration.ID)
|
||||
case models.IntegrationDiscord:
|
||||
authURL = h.getDiscordAuthURL(integration.ID)
|
||||
case models.IntegrationNotion:
|
||||
authURL = h.getNotionAuthURL(integration.ID)
|
||||
case models.IntegrationGoogle:
|
||||
authURL = h.getGoogleAuthURL(integration.ID)
|
||||
case models.IntegrationGitHub:
|
||||
authURL = h.getGitHubAuthURL(integration.ID)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "OAuth not supported for this integration type"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"authUrl": authURL})
|
||||
}
|
||||
|
||||
// OAuthCallback handles the OAuth callback
|
||||
func (h *IntegrationHandler) OAuthCallback(c *gin.Context) {
|
||||
integrationID := c.Query("state")
|
||||
code := c.Query("code")
|
||||
|
||||
if integrationID == "" || code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing required parameters"})
|
||||
return
|
||||
}
|
||||
|
||||
var integration models.Integration
|
||||
if err := h.db.Where("id = ?", integrationID).First(&integration).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Integration not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange code for tokens based on integration type
|
||||
var accessToken, refreshToken string
|
||||
var err error
|
||||
|
||||
switch integration.Type {
|
||||
case models.IntegrationSlack:
|
||||
accessToken, refreshToken, err = h.exchangeSlackCode(code)
|
||||
case models.IntegrationDiscord:
|
||||
accessToken, refreshToken, err = h.exchangeDiscordCode(code)
|
||||
case models.IntegrationNotion:
|
||||
accessToken, refreshToken, err = h.exchangeNotionCode(code)
|
||||
case models.IntegrationGoogle:
|
||||
accessToken, refreshToken, err = h.exchangeGoogleCode(code)
|
||||
case models.IntegrationGitHub:
|
||||
accessToken, refreshToken, err = h.exchangeGitHubCode(code)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported integration type"})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange authorization code"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update integration with tokens
|
||||
integration.AccessToken = accessToken
|
||||
integration.RefreshToken = refreshToken
|
||||
integration.Status = models.StatusActive
|
||||
|
||||
if err := h.db.Save(&integration).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update integration"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Integration authorized successfully", "integration": integration})
|
||||
}
|
||||
|
||||
// SyncIntegration manually triggers a sync for an integration
|
||||
func (h *IntegrationHandler) SyncIntegration(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
integrationID := c.Param("id")
|
||||
|
||||
var integration models.Integration
|
||||
if err := h.db.Where("id = ? AND user_id = ?", integrationID, userID).First(&integration).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Integration not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch integration"})
|
||||
return
|
||||
}
|
||||
|
||||
if integration.Status != models.StatusActive {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Integration is not active"})
|
||||
return
|
||||
}
|
||||
|
||||
// Start sync in background
|
||||
go h.performSync(integration)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Sync started"})
|
||||
}
|
||||
|
||||
// GetSyncLogs returns sync logs for an integration
|
||||
func (h *IntegrationHandler) GetSyncLogs(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
integrationID := c.Param("id")
|
||||
|
||||
// Verify integration belongs to user
|
||||
var integration models.Integration
|
||||
if err := h.db.Where("id = ? AND user_id = ?", integrationID, userID).First(&integration).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Integration not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch integration"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse pagination parameters
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset := (page - 1) * limit
|
||||
|
||||
var logs []models.SyncLog
|
||||
var total int64
|
||||
|
||||
if err := h.db.Where("integration_id = ?", integrationID).
|
||||
Model(&models.SyncLog{}).
|
||||
Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to count sync logs"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Where("integration_id = ?", integrationID).
|
||||
Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Offset(offset).
|
||||
Find(&logs).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch sync logs"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"logs": logs,
|
||||
"pagination": gin.H{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Helper methods for OAuth URLs (these would contain actual OAuth configuration)
|
||||
func (h *IntegrationHandler) getSlackAuthURL(integrationID string) string {
|
||||
return fmt.Sprintf("https://slack.com/oauth/v2/authorize?client_id=SLACK_CLIENT_ID&scope=commands,chat:write,users:read&redirect_uri=%s&state=%s",
|
||||
"http://localhost:8080/api/integrations/oauth/callback", integrationID)
|
||||
}
|
||||
|
||||
func (h *IntegrationHandler) getDiscordAuthURL(integrationID string) string {
|
||||
return fmt.Sprintf("https://discord.com/api/oauth2/authorize?client_id=DISCORD_CLIENT_ID&scope=bot&permissions=8&redirect_uri=%s&response_type=code&state=%s",
|
||||
"http://localhost:8080/api/integrations/oauth/callback", integrationID)
|
||||
}
|
||||
|
||||
func (h *IntegrationHandler) getNotionAuthURL(integrationID string) string {
|
||||
return fmt.Sprintf("https://api.notion.com/v1/oauth/authorize?client_id=NOTION_CLIENT_ID&redirect_uri=%s&response_type=code&state=%s",
|
||||
"http://localhost:8080/api/integrations/oauth/callback", integrationID)
|
||||
}
|
||||
|
||||
func (h *IntegrationHandler) getGoogleAuthURL(integrationID string) string {
|
||||
return fmt.Sprintf("https://accounts.google.com/o/oauth2/v2/auth?client_id=GOOGLE_CLIENT_ID&redirect_uri=%s&response_type=code&scope=https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/calendar&state=%s",
|
||||
"http://localhost:8080/api/integrations/oauth/callback", integrationID)
|
||||
}
|
||||
|
||||
func (h *IntegrationHandler) getGitHubAuthURL(integrationID string) string {
|
||||
return fmt.Sprintf("https://github.com/login/oauth/authorize?client_id=GITHUB_CLIENT_ID&redirect_uri=%s&scope=repo&state=%s",
|
||||
"http://localhost:8080/api/integrations/oauth/callback", integrationID)
|
||||
}
|
||||
|
||||
// Helper methods for token exchange (these would contain actual API calls)
|
||||
func (h *IntegrationHandler) exchangeSlackCode(code string) (string, string, error) {
|
||||
// TODO: Implement actual Slack token exchange
|
||||
return "mock_access_token", "mock_refresh_token", nil
|
||||
}
|
||||
|
||||
func (h *IntegrationHandler) exchangeDiscordCode(code string) (string, string, error) {
|
||||
// TODO: Implement actual Discord token exchange
|
||||
return "mock_access_token", "mock_refresh_token", nil
|
||||
}
|
||||
|
||||
func (h *IntegrationHandler) exchangeNotionCode(code string) (string, string, error) {
|
||||
// TODO: Implement actual Notion token exchange
|
||||
return "mock_access_token", "", nil // Notion doesn't use refresh tokens
|
||||
}
|
||||
|
||||
func (h *IntegrationHandler) exchangeGoogleCode(code string) (string, string, error) {
|
||||
// TODO: Implement actual Google token exchange
|
||||
return "mock_access_token", "mock_refresh_token", nil
|
||||
}
|
||||
|
||||
func (h *IntegrationHandler) exchangeGitHubCode(code string) (string, string, error) {
|
||||
// TODO: Implement actual GitHub token exchange
|
||||
return "mock_access_token", "", nil // GitHub tokens don't expire
|
||||
}
|
||||
|
||||
// performSync performs the actual sync operation
|
||||
func (h *IntegrationHandler) performSync(integration models.Integration) {
|
||||
startTime := time.Now()
|
||||
|
||||
syncLog := models.SyncLog{
|
||||
IntegrationID: integration.ID,
|
||||
Type: "manual",
|
||||
Status: "success",
|
||||
StartedAt: startTime,
|
||||
}
|
||||
|
||||
// Create initial sync log
|
||||
h.db.Create(&syncLog)
|
||||
|
||||
// Perform sync based on integration type
|
||||
var itemsProcessed, itemsCreated, itemsUpdated, itemsDeleted, itemsSkipped int
|
||||
var err error
|
||||
|
||||
switch integration.Type {
|
||||
case models.IntegrationSlack:
|
||||
itemsProcessed, itemsCreated, itemsUpdated, itemsDeleted, itemsSkipped, err = h.syncSlack(integration)
|
||||
case models.IntegrationDiscord:
|
||||
itemsProcessed, itemsCreated, itemsUpdated, itemsDeleted, itemsSkipped, err = h.syncDiscord(integration)
|
||||
case models.IntegrationNotion:
|
||||
itemsProcessed, itemsCreated, itemsUpdated, itemsDeleted, itemsSkipped, err = h.syncNotion(integration)
|
||||
case models.IntegrationGoogle:
|
||||
itemsProcessed, itemsCreated, itemsUpdated, itemsDeleted, itemsSkipped, err = h.syncGoogle(integration)
|
||||
case models.IntegrationGitHub:
|
||||
itemsProcessed, itemsCreated, itemsUpdated, itemsDeleted, itemsSkipped, err = h.syncGitHub(integration)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported integration type")
|
||||
}
|
||||
|
||||
// Update sync log
|
||||
completedAt := time.Now()
|
||||
duration := int(completedAt.Sub(startTime).Seconds())
|
||||
|
||||
if err != nil {
|
||||
syncLog.Status = "error"
|
||||
syncLog.ErrorMessage = err.Error()
|
||||
|
||||
// Update integration error count
|
||||
integration.ErrorCount++
|
||||
integration.LastError = err.Error()
|
||||
} else {
|
||||
syncLog.ItemsProcessed = itemsProcessed
|
||||
syncLog.ItemsCreated = itemsCreated
|
||||
syncLog.ItemsUpdated = itemsUpdated
|
||||
syncLog.ItemsDeleted = itemsDeleted
|
||||
syncLog.ItemsSkipped = itemsSkipped
|
||||
|
||||
// Update integration sync count
|
||||
integration.SyncCount++
|
||||
integration.LastError = ""
|
||||
}
|
||||
|
||||
syncLog.CompletedAt = &completedAt
|
||||
syncLog.Duration = duration
|
||||
|
||||
h.db.Save(&syncLog)
|
||||
|
||||
// Update integration
|
||||
integration.LastSyncAt = &completedAt
|
||||
h.db.Save(&integration)
|
||||
}
|
||||
|
||||
// Mock sync methods (these would contain actual API calls)
|
||||
func (h *IntegrationHandler) syncSlack(integration models.Integration) (int, int, int, int, int, error) {
|
||||
// TODO: Implement actual Slack sync
|
||||
return 0, 0, 0, 0, 0, nil
|
||||
}
|
||||
|
||||
func (h *IntegrationHandler) syncDiscord(integration models.Integration) (int, int, int, int, int, error) {
|
||||
// TODO: Implement actual Discord sync
|
||||
return 0, 0, 0, 0, 0, nil
|
||||
}
|
||||
|
||||
func (h *IntegrationHandler) syncNotion(integration models.Integration) (int, int, int, int, int, error) {
|
||||
// TODO: Implement actual Notion sync
|
||||
return 0, 0, 0, 0, 0, nil
|
||||
}
|
||||
|
||||
func (h *IntegrationHandler) syncGoogle(integration models.Integration) (int, int, int, int, int, error) {
|
||||
// TODO: Implement actual Google sync
|
||||
return 0, 0, 0, 0, 0, nil
|
||||
}
|
||||
|
||||
func (h *IntegrationHandler) syncGitHub(integration models.Integration) (int, int, int, int, int, error) {
|
||||
// TODO: Implement actual GitHub sync
|
||||
return 0, 0, 0, 0, 0, nil
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// KnowledgeBaseHandler handles knowledge base and wiki operations
|
||||
type KnowledgeBaseHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewKnowledgeBaseHandler creates a new knowledge base handler
|
||||
func NewKnowledgeBaseHandler(db *gorm.DB) *KnowledgeBaseHandler {
|
||||
return &KnowledgeBaseHandler{db: db}
|
||||
}
|
||||
|
||||
// Wiki Page Handlers
|
||||
|
||||
// CreateWikiPage creates a new wiki page
|
||||
func (h *KnowledgeBaseHandler) CreateWikiPage(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Content string `json:"content"`
|
||||
Summary string `json:"summary"`
|
||||
CategoryID *uint `json:"category_id"`
|
||||
ParentID *uint `json:"parent_id"`
|
||||
Tags []string `json:"tags"`
|
||||
Keywords []string `json:"keywords"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
TemplateID *uint `json:"template_id"`
|
||||
IsCollaborative bool `json:"is_collaborative"`
|
||||
CollaboratorIDs []uint `json:"collaborator_ids"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate slug from title
|
||||
slug := generateSlug(req.Title)
|
||||
|
||||
// Check if slug already exists
|
||||
var existingPage models.WikiPage
|
||||
if err := h.db.Where("slug = ? AND user_id = ?", slug, userID).First(&existingPage).Error; err == nil {
|
||||
// Slug exists, append timestamp
|
||||
slug = slug + "-" + strconv.FormatInt(time.Now().Unix(), 10)
|
||||
}
|
||||
|
||||
page := models.WikiPage{
|
||||
UserID: userID,
|
||||
Title: req.Title,
|
||||
Slug: slug,
|
||||
Content: req.Content,
|
||||
Summary: req.Summary,
|
||||
CategoryID: req.CategoryID,
|
||||
ParentID: req.ParentID,
|
||||
Keywords: req.Keywords,
|
||||
IsPublic: req.IsPublic,
|
||||
IsTemplate: req.IsTemplate,
|
||||
TemplateID: req.TemplateID,
|
||||
IsCollaborative: req.IsCollaborative,
|
||||
Status: "draft",
|
||||
}
|
||||
|
||||
// Calculate word count and reading time
|
||||
if req.Content != "" {
|
||||
page.WordCount = len(strings.Fields(req.Content))
|
||||
page.ReadingTime = estimateReadingTime(page.WordCount)
|
||||
}
|
||||
|
||||
// Create page
|
||||
if err := h.db.Create(&page).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create wiki page"})
|
||||
return
|
||||
}
|
||||
|
||||
// Handle tags
|
||||
if len(req.Tags) > 0 {
|
||||
h.addTagsToWikiPage(page.ID, req.Tags, userID)
|
||||
}
|
||||
|
||||
// Handle collaborators
|
||||
if len(req.CollaboratorIDs) > 0 {
|
||||
h.addCollaboratorsToWikiPage(page.ID, req.CollaboratorIDs)
|
||||
}
|
||||
|
||||
// Create initial version
|
||||
h.createWikiVersion(page.ID, 1, userID, "Initial version")
|
||||
|
||||
// Process content for backlinks
|
||||
go h.processBacklinks(page.ID, req.Content)
|
||||
|
||||
c.JSON(http.StatusCreated, page)
|
||||
}
|
||||
|
||||
// GetWikiPages retrieves user's wiki pages
|
||||
func (h *KnowledgeBaseHandler) GetWikiPages(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
categoryID := c.Query("category_id")
|
||||
status := c.Query("status")
|
||||
search := c.Query("search")
|
||||
isPublic := c.Query("is_public")
|
||||
limit := 20
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
query := h.db.Where("user_id = ?", userID)
|
||||
|
||||
if categoryID != "" {
|
||||
query = query.Where("category_id = ?", categoryID)
|
||||
}
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
if search != "" {
|
||||
query = query.Where("title ILIKE ? OR content ILIKE ?", "%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
if isPublic == "true" {
|
||||
query = query.Where("is_public = ?", true)
|
||||
}
|
||||
|
||||
var pages []models.WikiPage
|
||||
if err := query.Preload("Category").Preload("Tags").Order("updated_at DESC").Limit(limit).Find(&pages).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch wiki pages"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"pages": pages,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// GetWikiPage retrieves a specific wiki page
|
||||
func (h *KnowledgeBaseHandler) GetWikiPage(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
pageID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid page ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var page models.WikiPage
|
||||
if err := h.db.Where("id = ? AND user_id = ?", pageID, userID).
|
||||
Preload("Category").
|
||||
Preload("Tags").
|
||||
Preload("Collaborators").
|
||||
Preload("LastEditedUser").
|
||||
First(&page).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Wiki page not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Increment view count
|
||||
h.db.Model(&page).UpdateColumn("view_count", gorm.Expr("view_count + ?", 1))
|
||||
h.db.Model(&page).Update("last_viewed_at", time.Now())
|
||||
|
||||
c.JSON(http.StatusOK, page)
|
||||
}
|
||||
|
||||
// UpdateWikiPage updates a wiki page
|
||||
func (h *KnowledgeBaseHandler) UpdateWikiPage(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
pageID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid page ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var page models.WikiPage
|
||||
if err := h.db.Where("id = ? AND user_id = ?", pageID, userID).First(&page).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Wiki page not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Summary string `json:"summary"`
|
||||
CategoryID *uint `json:"category_id"`
|
||||
Tags []string `json:"tags"`
|
||||
Keywords []string `json:"keywords"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
Status string `json:"status"`
|
||||
ChangeLog string `json:"change_log"`
|
||||
IsMinorChange bool `json:"is_minor_change"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Store old content for version tracking
|
||||
oldContent := page.Content
|
||||
|
||||
// Update fields
|
||||
if req.Title != "" {
|
||||
page.Title = req.Title
|
||||
page.Slug = generateSlug(req.Title)
|
||||
}
|
||||
if req.Content != "" {
|
||||
page.Content = req.Content
|
||||
}
|
||||
if req.Summary != "" {
|
||||
page.Summary = req.Summary
|
||||
}
|
||||
if req.CategoryID != nil {
|
||||
page.CategoryID = req.CategoryID
|
||||
}
|
||||
if req.Keywords != nil {
|
||||
page.Keywords = req.Keywords
|
||||
}
|
||||
page.IsPublic = req.IsPublic
|
||||
if req.Status != "" {
|
||||
page.Status = req.Status
|
||||
}
|
||||
|
||||
// Update metadata
|
||||
page.LastEditedBy = &userID
|
||||
page.EditCount++
|
||||
|
||||
// Calculate word count and reading time
|
||||
if req.Content != "" {
|
||||
page.WordCount = len(strings.Fields(req.Content))
|
||||
page.ReadingTime = estimateReadingTime(page.WordCount)
|
||||
}
|
||||
|
||||
if err := h.db.Save(&page).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update wiki page"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update tags
|
||||
if req.Tags != nil {
|
||||
h.updateWikiPageTags(page.ID, req.Tags, userID)
|
||||
}
|
||||
|
||||
// Create new version if content changed
|
||||
if req.Content != "" && req.Content != oldContent {
|
||||
lastVersion := h.getLastWikiVersion(page.ID)
|
||||
newVersion := lastVersion + 1
|
||||
h.createWikiVersion(page.ID, newVersion, userID, req.ChangeLog)
|
||||
}
|
||||
|
||||
// Process backlinks if content changed
|
||||
if req.Content != "" && req.Content != oldContent {
|
||||
go h.processBacklinks(page.ID, req.Content)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, page)
|
||||
}
|
||||
|
||||
// DeleteWikiPage deletes a wiki page
|
||||
func (h *KnowledgeBaseHandler) DeleteWikiPage(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
pageID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid page ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var page models.WikiPage
|
||||
if err := h.db.Where("id = ? AND user_id = ?", pageID, userID).First(&page).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Wiki page not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Delete(&page).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete wiki page"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Wiki page deleted successfully"})
|
||||
}
|
||||
|
||||
// Category Handlers
|
||||
|
||||
// CreateCategory creates a new category
|
||||
func (h *KnowledgeBaseHandler) CreateCategory(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Color string `json:"color"`
|
||||
Icon string `json:"icon"`
|
||||
ParentID *uint `json:"parent_id"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
slug := generateSlug(req.Name)
|
||||
|
||||
// Check if slug already exists
|
||||
var existingCategory models.Category
|
||||
if err := h.db.Where("slug = ? AND user_id = ?", slug, userID).First(&existingCategory).Error; err == nil {
|
||||
slug = slug + "-" + strconv.FormatInt(time.Now().Unix(), 10)
|
||||
}
|
||||
|
||||
category := models.Category{
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Slug: slug,
|
||||
Description: req.Description,
|
||||
Color: req.Color,
|
||||
Icon: req.Icon,
|
||||
ParentID: req.ParentID,
|
||||
IsPublic: req.IsPublic,
|
||||
}
|
||||
|
||||
if req.Color == "" {
|
||||
category.Color = "#6366f1"
|
||||
}
|
||||
|
||||
if err := h.db.Create(&category).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create category"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, category)
|
||||
}
|
||||
|
||||
// GetCategories retrieves user's categories
|
||||
func (h *KnowledgeBaseHandler) GetCategories(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var categories []models.Category
|
||||
if err := h.db.Where("user_id = ?", userID).
|
||||
Preload("Children").
|
||||
Order("sort_order ASC, name ASC").
|
||||
Find(&categories).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch categories"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, categories)
|
||||
}
|
||||
|
||||
// SearchWikiPages searches within wiki pages
|
||||
func (h *KnowledgeBaseHandler) SearchWikiPages(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Search query is required"})
|
||||
return
|
||||
}
|
||||
|
||||
contentType := c.Query("content_type")
|
||||
categoryID := c.Query("category_id")
|
||||
limit := 20
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Build search query
|
||||
dbQuery := h.db.Where("user_id = ?", userID)
|
||||
|
||||
// Search in title, content, and summary
|
||||
searchCondition := h.db.Where("title ILIKE ?", "%"+query+"%").
|
||||
Or("content ILIKE ?", "%"+query+"%").
|
||||
Or("summary ILIKE ?", "%"+query+"%")
|
||||
|
||||
dbQuery = dbQuery.Where(searchCondition)
|
||||
|
||||
if contentType != "" {
|
||||
dbQuery = dbQuery.Where("content_type = ?", contentType)
|
||||
}
|
||||
if categoryID != "" {
|
||||
dbQuery = dbQuery.Where("category_id = ?", categoryID)
|
||||
}
|
||||
|
||||
var pages []models.WikiPage
|
||||
if err := dbQuery.Preload("Category").Preload("Tags").
|
||||
Order("updated_at DESC").Limit(limit).Find(&pages).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search wiki pages"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"pages": pages,
|
||||
"query": query,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func generateSlug(title string) string {
|
||||
slug := strings.ToLower(title)
|
||||
slug = strings.ReplaceAll(slug, " ", "-")
|
||||
slug = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(slug, "")
|
||||
slug = regexp.MustCompile(`-+`).ReplaceAllString(slug, "-")
|
||||
slug = strings.Trim(slug, "-")
|
||||
return slug
|
||||
}
|
||||
|
||||
func estimateReadingTime(wordCount int) int {
|
||||
readingSpeed := 225
|
||||
readingTime := wordCount / readingSpeed
|
||||
if readingTime < 1 {
|
||||
readingTime = 1
|
||||
}
|
||||
return readingTime
|
||||
}
|
||||
|
||||
func (h *KnowledgeBaseHandler) addTagsToWikiPage(pageID uint, tags []string, userID uint) {
|
||||
for _, tagName := range tags {
|
||||
var tag models.Tag
|
||||
if err := h.db.Where("name = ? AND user_id = ?", tagName, userID).First(&tag).Error; err != nil {
|
||||
// Create new tag
|
||||
tag = models.Tag{
|
||||
UserID: userID,
|
||||
Name: tagName,
|
||||
Color: "#6366f1",
|
||||
}
|
||||
h.db.Create(&tag)
|
||||
}
|
||||
|
||||
// Associate tag with page
|
||||
h.db.Exec("INSERT INTO wiki_page_tags (wiki_page_id, tag_id) VALUES (?, ?) ON CONFLICT DO NOTHING", pageID, tag.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *KnowledgeBaseHandler) updateWikiPageTags(pageID uint, tags []string, userID uint) {
|
||||
// Remove existing tags
|
||||
h.db.Exec("DELETE FROM wiki_page_tags WHERE wiki_page_id = ?", pageID)
|
||||
|
||||
// Add new tags
|
||||
h.addTagsToWikiPage(pageID, tags, userID)
|
||||
}
|
||||
|
||||
func (h *KnowledgeBaseHandler) addCollaboratorsToWikiPage(pageID uint, collaboratorIDs []uint) {
|
||||
for _, collaboratorID := range collaboratorIDs {
|
||||
h.db.Exec("INSERT INTO wiki_collaborators (wiki_page_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING", pageID, collaboratorID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *KnowledgeBaseHandler) createWikiVersion(pageID uint, versionNumber int, authorID uint, changeLog string) {
|
||||
var page models.WikiPage
|
||||
h.db.First(&page, pageID)
|
||||
|
||||
version := models.WikiVersion{
|
||||
WikiPageID: pageID,
|
||||
VersionNumber: versionNumber,
|
||||
Title: page.Title,
|
||||
Content: page.Content,
|
||||
Summary: page.Summary,
|
||||
ChangeLog: changeLog,
|
||||
AuthorID: authorID,
|
||||
WordCount: page.WordCount,
|
||||
IsMinorChange: changeLog == "" || strings.Contains(strings.ToLower(changeLog), "minor"),
|
||||
}
|
||||
|
||||
h.db.Create(&version)
|
||||
}
|
||||
|
||||
func (h *KnowledgeBaseHandler) getLastWikiVersion(pageID uint) int {
|
||||
var version models.WikiVersion
|
||||
h.db.Where("wiki_page_id = ?", pageID).Order("version_number DESC").First(&version)
|
||||
return version.VersionNumber
|
||||
}
|
||||
|
||||
func (h *KnowledgeBaseHandler) processBacklinks(pageID uint, content string) {
|
||||
// Extract wiki links from content (e.g., [[Page Name]])
|
||||
re := regexp.MustCompile(`\[\[([^\]]+)\]\]`)
|
||||
matches := re.FindAllStringSubmatch(content, -1)
|
||||
|
||||
for _, match := range matches {
|
||||
if len(match) > 1 {
|
||||
linkText := match[1]
|
||||
// Find target page
|
||||
var targetPage models.WikiPage
|
||||
if err := h.db.Where("title = ? OR slug = ?", linkText, linkText).First(&targetPage).Error; err == nil {
|
||||
// Create backlink
|
||||
backlink := models.WikiBacklink{
|
||||
SourcePageID: pageID,
|
||||
TargetPageID: targetPage.ID,
|
||||
LinkText: linkText,
|
||||
}
|
||||
h.db.Create(&backlink)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// GetLearningPaths handles GET /api/v1/learning-paths
|
||||
func GetLearningPaths(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
var learningPaths []models.LearningPath
|
||||
|
||||
// Parse query parameters
|
||||
category := c.Query("category")
|
||||
difficulty := c.Query("difficulty")
|
||||
featured := c.Query("featured")
|
||||
search := c.Query("search")
|
||||
|
||||
query := db.Where("is_published = ?", true)
|
||||
|
||||
// Add filters
|
||||
if category != "" {
|
||||
query = query.Where("category = ?", category)
|
||||
}
|
||||
if difficulty != "" {
|
||||
query = query.Where("difficulty = ?", difficulty)
|
||||
}
|
||||
if featured == "true" {
|
||||
query = query.Where("is_featured = ?", true)
|
||||
}
|
||||
if search != "" {
|
||||
query = query.Where("title ILIKE ? OR description ILIKE ?", "%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
|
||||
// Preload relationships
|
||||
if err := query.Preload("Creator").Preload("Tags").Preload("Modules").Find(&learningPaths).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch learning paths"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, learningPaths)
|
||||
}
|
||||
|
||||
// GetLearningPath handles GET /api/v1/learning-paths/:id
|
||||
func GetLearningPath(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid learning path ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var learningPath models.LearningPath
|
||||
if err := db.Where("id = ? AND is_published = ?", id, true).
|
||||
Preload("Creator").
|
||||
Preload("Tags").
|
||||
Preload("Modules", "ORDER BY \"order\" ASC").
|
||||
Preload("Modules.Resources", "ORDER BY \"order\" ASC").
|
||||
First(&learningPath).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Learning path not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, learningPath)
|
||||
}
|
||||
|
||||
// CreateLearningPath handles POST /api/v1/learning-paths
|
||||
func CreateLearningPath(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
var learningPath models.LearningPath
|
||||
|
||||
if err := c.ShouldBindJSON(&learningPath); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user ID from auth middleware
|
||||
userID := c.GetUint("userID")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
learningPath.CreatorID = userID
|
||||
|
||||
// Create learning path
|
||||
if err := db.Create(&learningPath).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create learning path"})
|
||||
return
|
||||
}
|
||||
|
||||
// Preload relationships for response
|
||||
db.Preload("Creator").Preload("Tags").First(&learningPath, learningPath.ID)
|
||||
|
||||
c.JSON(http.StatusCreated, learningPath)
|
||||
}
|
||||
|
||||
// UpdateLearningPath handles PUT /api/v1/learning-paths/:id
|
||||
func UpdateLearningPath(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid learning path ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var learningPath models.LearningPath
|
||||
userID := c.GetUint("userID")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
// Find existing learning path (creator or admin only)
|
||||
if err := db.Where("id = ? AND creator_id = ?", id, userID).First(&learningPath).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Learning path not found or no permission"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update fields
|
||||
var updateData models.LearningPath
|
||||
if err := c.ShouldBindJSON(&updateData); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Model(&learningPath).Updates(updateData).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update learning path"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get updated learning path with relationships
|
||||
db.Preload("Creator").Preload("Tags").Preload("Modules").First(&learningPath, learningPath.ID)
|
||||
|
||||
c.JSON(http.StatusOK, learningPath)
|
||||
}
|
||||
|
||||
// DeleteLearningPath handles DELETE /api/v1/learning-paths/:id
|
||||
func DeleteLearningPath(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid learning path ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var learningPath models.LearningPath
|
||||
userID := c.GetUint("userID")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
// Find and delete learning path (creator or admin only)
|
||||
if err := db.Where("id = ? AND creator_id = ?", id, userID).First(&learningPath).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Learning path not found or no permission"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Delete(&learningPath).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete learning path"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Learning path deleted successfully"})
|
||||
}
|
||||
|
||||
// EnrollInLearningPath handles POST /api/v1/learning-paths/:id/enroll
|
||||
func EnrollInLearningPath(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
pathID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid learning path ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetUint("userID")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if learning path exists
|
||||
var learningPath models.LearningPath
|
||||
if err := db.First(&learningPath, pathID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Learning path not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already enrolled
|
||||
var existingEnrollment models.Enrollment
|
||||
if err := db.Where("user_id = ? AND learning_path_id = ?", userID, pathID).First(&existingEnrollment).Error; err == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Already enrolled in this learning path"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create enrollment
|
||||
enrollment := models.Enrollment{
|
||||
UserID: userID,
|
||||
LearningPathID: uint(pathID),
|
||||
Status: "enrolled",
|
||||
Progress: 0,
|
||||
CompletedModules: []uint{},
|
||||
}
|
||||
|
||||
if err := db.Create(&enrollment).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enroll in learning path"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update enrollment count
|
||||
db.Model(&learningPath).UpdateColumn("enrollment_count", learningPath.EnrollmentCount + 1)
|
||||
|
||||
c.JSON(http.StatusCreated, enrollment)
|
||||
}
|
||||
|
||||
// GetUserEnrollments handles GET /api/v1/enrollments
|
||||
func GetUserEnrollments(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
userID := c.GetUint("userID")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var enrollments []models.Enrollment
|
||||
if err := db.Where("user_id = ?", userID).
|
||||
Preload("LearningPath").
|
||||
Preload("LearningPath.Creator").
|
||||
Preload("LearningPath.Tags").
|
||||
Find(&enrollments).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch enrollments"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, enrollments)
|
||||
}
|
||||
|
||||
// UpdateProgress handles PUT /api/v1/enrollments/:id/progress
|
||||
func UpdateProgress(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
enrollmentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid enrollment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetUint("userID")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var input struct {
|
||||
ModuleID uint `json:"module_id"`
|
||||
Status string `json:"status"`
|
||||
Progress float64 `json:"progress"`
|
||||
CompletedModules []uint `json:"completed_modules"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Find enrollment
|
||||
var enrollment models.Enrollment
|
||||
if err := db.Where("id = ? AND user_id = ?", enrollmentID, userID).First(&enrollment).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Enrollment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update enrollment
|
||||
now := time.Now()
|
||||
if enrollment.Status == "enrolled" {
|
||||
enrollment.StartedAt = &now
|
||||
enrollment.Status = "in_progress"
|
||||
}
|
||||
|
||||
enrollment.Progress = input.Progress
|
||||
enrollment.CompletedModules = input.CompletedModules
|
||||
enrollment.CurrentModuleID = &input.ModuleID
|
||||
|
||||
// Check if completed
|
||||
if input.Progress >= 100 {
|
||||
enrollment.Status = "completed"
|
||||
enrollment.CompletedAt = &now
|
||||
}
|
||||
|
||||
if err := db.Save(&enrollment).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update progress"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, enrollment)
|
||||
}
|
||||
|
||||
// RateLearningPath handles POST /api/v1/enrollments/:id/rate
|
||||
func RateLearningPath(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
enrollmentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid enrollment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetUint("userID")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var input struct {
|
||||
Rating float64 `json:"rating" binding:"required,min=1,max=5"`
|
||||
Review string `json:"review"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Find enrollment
|
||||
var enrollment models.Enrollment
|
||||
if err := db.Where("id = ? AND user_id = ?", enrollmentID, userID).First(&enrollment).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Enrollment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update enrollment with rating
|
||||
now := time.Now()
|
||||
enrollment.Rating = &input.Rating
|
||||
enrollment.Review = input.Review
|
||||
enrollment.ReviewDate = &now
|
||||
|
||||
if err := db.Save(&enrollment).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save rating"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update learning path rating
|
||||
var learningPath models.LearningPath
|
||||
db.First(&learningPath, enrollment.LearningPathID)
|
||||
|
||||
// Recalculate average rating
|
||||
var avgRating struct {
|
||||
AvgRating float64
|
||||
Count int
|
||||
}
|
||||
|
||||
db.Model(&models.Enrollment{}).
|
||||
Select("AVG(rating) as avg_rating, COUNT(*) as count").
|
||||
Where("learning_path_id = ? AND rating IS NOT NULL", enrollment.LearningPathID).
|
||||
Scan(&avgRating)
|
||||
|
||||
learningPath.Rating = avgRating.AvgRating
|
||||
learningPath.ReviewCount = avgRating.Count
|
||||
db.Save(&learningPath)
|
||||
|
||||
c.JSON(http.StatusOK, enrollment)
|
||||
}
|
||||
|
||||
// GetLearningPathCategories handles GET /api/v1/learning-paths/categories
|
||||
func GetLearningPathCategories(c *gin.Context) {
|
||||
categories := []string{
|
||||
"programming",
|
||||
"web-development",
|
||||
"mobile-development",
|
||||
"data-science",
|
||||
"machine-learning",
|
||||
"cybersecurity",
|
||||
"design",
|
||||
"business",
|
||||
"marketing",
|
||||
"photography",
|
||||
"music",
|
||||
"writing",
|
||||
"languages",
|
||||
"other",
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"categories": categories})
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LearningProgressHandler handles learning progress operations
|
||||
type LearningProgressHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewLearningProgressHandler creates a new learning progress handler
|
||||
func NewLearningProgressHandler(db *gorm.DB) *LearningProgressHandler {
|
||||
return &LearningProgressHandler{db: db}
|
||||
}
|
||||
|
||||
// UpdateLearningProgress updates learning analytics when user interacts with course
|
||||
func (h *LearningProgressHandler) UpdateLearningProgress(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req struct {
|
||||
CourseID uint `json:"course_id" binding:"required"`
|
||||
TimeSpent float64 `json:"time_spent"` // in hours
|
||||
Progress float64 `json:"progress"` // percentage 0-100
|
||||
ModuleID *uint `json:"module_id,omitempty"`
|
||||
QuizScore *float64 `json:"quiz_score,omitempty"`
|
||||
Skills []string `json:"skills,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get course information
|
||||
var course models.Course
|
||||
if err := h.db.First(&course, req.CourseID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Course not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get or create learning analytics
|
||||
var learningAnalytics models.LearningAnalytics
|
||||
err := h.db.Where("user_id = ? AND course_id = ?", userID, req.CourseID).
|
||||
Preload("Course").
|
||||
First(&learningAnalytics).Error
|
||||
|
||||
if err != nil {
|
||||
// Create new learning analytics
|
||||
averageScore := 0.0
|
||||
if req.QuizScore != nil {
|
||||
averageScore = *req.QuizScore
|
||||
}
|
||||
learningAnalytics = models.LearningAnalytics{
|
||||
UserID: userID,
|
||||
CourseID: req.CourseID,
|
||||
StartDate: time.Now(),
|
||||
LastAccessed: time.Now(),
|
||||
TimeSpent: req.TimeSpent,
|
||||
Progress: req.Progress,
|
||||
ModulesCompleted: 0,
|
||||
TotalModules: course.ModuleCount,
|
||||
AverageScore: averageScore,
|
||||
StreakDays: 1,
|
||||
SkillsAcquired: req.Skills,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&learningAnalytics).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create learning analytics"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Update existing analytics
|
||||
learningAnalytics.LastAccessed = time.Now()
|
||||
learningAnalytics.TimeSpent += req.TimeSpent
|
||||
learningAnalytics.Progress = req.Progress
|
||||
|
||||
// Update modules completed if progress increased
|
||||
if req.ModuleID != nil {
|
||||
learningAnalytics.ModulesCompleted++
|
||||
}
|
||||
|
||||
// Update quiz scores
|
||||
if req.QuizScore != nil {
|
||||
if learningAnalytics.QuizScores == nil {
|
||||
learningAnalytics.QuizScores = []float64{*req.QuizScore}
|
||||
} else {
|
||||
learningAnalytics.QuizScores = append(learningAnalytics.QuizScores, *req.QuizScore)
|
||||
}
|
||||
|
||||
// Calculate average score
|
||||
sum := 0.0
|
||||
for _, score := range learningAnalytics.QuizScores {
|
||||
sum += score
|
||||
}
|
||||
learningAnalytics.AverageScore = sum / float64(len(learningAnalytics.QuizScores))
|
||||
}
|
||||
|
||||
// Update skills
|
||||
if len(req.Skills) > 0 {
|
||||
skillsMap := make(map[string]bool)
|
||||
for _, skill := range learningAnalytics.SkillsAcquired {
|
||||
skillsMap[skill] = true
|
||||
}
|
||||
for _, skill := range req.Skills {
|
||||
if !skillsMap[skill] {
|
||||
learningAnalytics.SkillsAcquired = append(learningAnalytics.SkillsAcquired, skill)
|
||||
skillsMap[skill] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update streak
|
||||
learningAnalytics.StreakDays = h.calculateLearningStreak(userID, req.CourseID)
|
||||
|
||||
if err := h.db.Save(&learningAnalytics).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update learning analytics"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check if course is completed
|
||||
if learningAnalytics.Progress >= 100 && !learningAnalytics.CourseCompleted {
|
||||
learningAnalytics.CourseCompleted = true
|
||||
learningAnalytics.CompletedAt = &time.Time{}
|
||||
*learningAnalytics.CompletedAt = time.Now()
|
||||
h.db.Save(&learningAnalytics)
|
||||
|
||||
// Update enrollment if exists
|
||||
var enrollment models.Enrollment
|
||||
if err := h.db.Where("user_id = ? AND course_id = ?", userID, req.CourseID).
|
||||
First(&enrollment).Error; err == nil {
|
||||
enrollment.CompletedAt = learningAnalytics.CompletedAt
|
||||
enrollment.Status = "completed"
|
||||
h.db.Save(&enrollment)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, learningAnalytics)
|
||||
}
|
||||
|
||||
// GetLearningProgress returns detailed learning progress for a user
|
||||
func (h *LearningProgressHandler) GetLearningProgress(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var learningAnalytics []models.LearningAnalytics
|
||||
if err := h.db.Where("user_id = ?", userID).
|
||||
Preload("Course").
|
||||
Order("last_accessed DESC").
|
||||
Find(&learningAnalytics).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch learning progress"})
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate overall statistics
|
||||
totalCourses := len(learningAnalytics)
|
||||
completedCourses := 0
|
||||
inProgressCourses := 0
|
||||
totalTimeSpent := 0.0
|
||||
totalSkills := make(map[string]bool)
|
||||
|
||||
for _, la := range learningAnalytics {
|
||||
totalTimeSpent += la.TimeSpent
|
||||
|
||||
if la.Progress >= 100 {
|
||||
completedCourses++
|
||||
} else if la.Progress > 0 {
|
||||
inProgressCourses++
|
||||
}
|
||||
|
||||
for _, skill := range la.SkillsAcquired {
|
||||
totalSkills[skill] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Get recent activity
|
||||
var recentActivity []models.LearningAnalytics
|
||||
if err := h.db.Where("user_id = ? AND last_accessed >= ?",
|
||||
userID, time.Now().AddDate(0, 0, -7)).
|
||||
Preload("Course").
|
||||
Order("last_accessed DESC").
|
||||
Find(&recentActivity).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch recent activity"})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert skills map to slice
|
||||
skillsList := make([]string, 0, len(totalSkills))
|
||||
for skill := range totalSkills {
|
||||
skillsList = append(skillsList, skill)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"learning_progress": learningAnalytics,
|
||||
"statistics": gin.H{
|
||||
"total_courses": totalCourses,
|
||||
"completed_courses": completedCourses,
|
||||
"in_progress_courses": inProgressCourses,
|
||||
"total_time_spent": totalTimeSpent,
|
||||
"total_skills": len(skillsList),
|
||||
"skills_acquired": skillsList,
|
||||
},
|
||||
"recent_activity": recentActivity,
|
||||
})
|
||||
}
|
||||
|
||||
// GetCourseProgress returns detailed progress for a specific course
|
||||
func (h *LearningProgressHandler) GetCourseProgress(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
courseID, err := strconv.ParseUint(c.Param("courseId"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid course ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var learningAnalytics models.LearningAnalytics
|
||||
if err := h.db.Where("user_id = ? AND course_id = ?", userID, courseID).
|
||||
Preload("Course").
|
||||
First(&learningAnalytics).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Course progress not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get detailed module progress if available
|
||||
var moduleProgress []gin.H
|
||||
// This would be implemented based on your course structure
|
||||
// For now, return placeholder data
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"course_progress": learningAnalytics,
|
||||
"module_progress": moduleProgress,
|
||||
"insights": h.generateLearningInsights(learningAnalytics),
|
||||
})
|
||||
}
|
||||
|
||||
// MarkCourseCompleted marks a course as completed
|
||||
func (h *LearningProgressHandler) MarkCourseCompleted(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
courseID, err := strconv.ParseUint(c.Param("courseId"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid course ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var learningAnalytics models.LearningAnalytics
|
||||
if err := h.db.Where("user_id = ? AND course_id = ?", userID, courseID).
|
||||
First(&learningAnalytics).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Course progress not found"})
|
||||
return
|
||||
}
|
||||
|
||||
learningAnalytics.Progress = 100
|
||||
learningAnalytics.CourseCompleted = true
|
||||
completedAt := time.Now()
|
||||
learningAnalytics.CompletedAt = &completedAt
|
||||
|
||||
if err := h.db.Save(&learningAnalytics).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark course as completed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update enrollment
|
||||
var enrollment models.Enrollment
|
||||
if err := h.db.Where("user_id = ? AND course_id = ?", userID, courseID).
|
||||
First(&enrollment).Error; err == nil {
|
||||
enrollment.CompletedAt = &completedAt
|
||||
enrollment.Status = "completed"
|
||||
h.db.Save(&enrollment)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Course marked as completed",
|
||||
"course_progress": learningAnalytics,
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func (h *LearningProgressHandler) calculateLearningStreak(userID uint, courseID uint) int {
|
||||
// Calculate consecutive days with learning activity for this course
|
||||
streak := 0
|
||||
currentDate := time.Now().Truncate(24 * time.Hour)
|
||||
|
||||
for i := 0; i < 365; i++ { // Check up to a year back
|
||||
checkDate := currentDate.AddDate(0, 0, -i)
|
||||
|
||||
var count int64
|
||||
h.db.Model(&models.LearningAnalytics{}).
|
||||
Where("user_id = ? AND course_id = ? AND DATE(last_accessed) = ?",
|
||||
userID, courseID, checkDate.Format("2006-01-02")).
|
||||
Count(&count)
|
||||
|
||||
if count > 0 {
|
||||
streak++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return streak
|
||||
}
|
||||
|
||||
func (h *LearningProgressHandler) generateLearningInsights(analytics models.LearningAnalytics) []string {
|
||||
insights := []string{}
|
||||
|
||||
// Generate insights based on learning patterns
|
||||
if analytics.TimeSpent > 50 {
|
||||
insights = append(insights, "You've dedicated over 50 hours to this course!")
|
||||
}
|
||||
|
||||
if analytics.StreakDays > 7 {
|
||||
insights = append(insights, "Great consistency! You've maintained a learning streak for over a week.")
|
||||
}
|
||||
|
||||
if analytics.AverageScore > 85 {
|
||||
insights = append(insights, "Excellent quiz performance! You're mastering the material.")
|
||||
}
|
||||
|
||||
if analytics.Progress > 80 && analytics.Progress < 100 {
|
||||
insights = append(insights, "You're almost there! Just a bit more to complete this course.")
|
||||
}
|
||||
|
||||
if len(analytics.SkillsAcquired) > 5 {
|
||||
insights = append(insights, "You've acquired numerous skills from this course.")
|
||||
}
|
||||
|
||||
return insights
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type MarketplaceHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewMarketplaceHandler(db *gorm.DB) *MarketplaceHandler {
|
||||
return &MarketplaceHandler{db: db}
|
||||
}
|
||||
|
||||
// GetMarketplaceItems returns all marketplace items with filtering
|
||||
func (h *MarketplaceHandler) GetMarketplaceItems(c *gin.Context) {
|
||||
var items []models.MarketplaceItem
|
||||
query := h.db.Preload("Seller").Preload("Tags")
|
||||
|
||||
// Filter by category
|
||||
if category := c.Query("category"); category != "" {
|
||||
query = query.Where("category = ?", category)
|
||||
}
|
||||
|
||||
// Filter by content type
|
||||
if contentType := c.Query("content_type"); contentType != "" {
|
||||
query = query.Where("content_type = ?", contentType)
|
||||
}
|
||||
|
||||
// Filter by price range
|
||||
if minPrice := c.Query("min_price"); minPrice != "" {
|
||||
if price, err := strconv.ParseFloat(minPrice, 64); err == nil {
|
||||
query = query.Where("price >= ?", price)
|
||||
}
|
||||
}
|
||||
if maxPrice := c.Query("max_price"); maxPrice != "" {
|
||||
if price, err := strconv.ParseFloat(maxPrice, 64); err == nil {
|
||||
query = query.Where("price <= ?", price)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by free items
|
||||
if isFree := c.Query("is_free"); isFree == "true" {
|
||||
query = query.Where("is_free = ?", true)
|
||||
}
|
||||
|
||||
// Filter by featured items
|
||||
if featured := c.Query("featured"); featured == "true" {
|
||||
query = query.Where("is_featured = ?", true)
|
||||
}
|
||||
|
||||
// Filter by status (only show published items for public)
|
||||
query = query.Where("status = ? AND is_approved = ?", "published", true)
|
||||
|
||||
// Search by title or description
|
||||
if search := c.Query("search"); search != "" {
|
||||
// Escape special SQL characters to prevent SQL injection
|
||||
escapedSearch := strings.ReplaceAll(search, "%", "\\%")
|
||||
escapedSearch = strings.ReplaceAll(escapedSearch, "_", "\\_")
|
||||
query = query.Where("title ILIKE ? OR description ILIKE ?", "%"+escapedSearch+"%", "%"+escapedSearch+"%")
|
||||
}
|
||||
|
||||
// Sort by
|
||||
sortBy := c.DefaultQuery("sort", "created_at")
|
||||
switch sortBy {
|
||||
case "rating":
|
||||
query = query.Order("rating DESC, review_count DESC")
|
||||
case "downloads":
|
||||
query = query.Order("download_count DESC")
|
||||
case "price_low":
|
||||
query = query.Order("price ASC")
|
||||
case "price_high":
|
||||
query = query.Order("price DESC")
|
||||
case "views":
|
||||
query = query.Order("view_count DESC")
|
||||
case "created_at":
|
||||
query = query.Order("created_at DESC")
|
||||
default:
|
||||
query = query.Order("created_at DESC")
|
||||
}
|
||||
|
||||
// Pagination
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset := (page - 1) * limit
|
||||
|
||||
var total int64
|
||||
query.Model(&models.MarketplaceItem{}).Count(&total)
|
||||
|
||||
if err := query.Offset(offset).Limit(limit).Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch marketplace items"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"pagination": gin.H{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"pages": (total + int64(limit) - 1) / int64(limit),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetMarketplaceItem returns a specific marketplace item
|
||||
func (h *MarketplaceHandler) GetMarketplaceItem(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var item models.MarketplaceItem
|
||||
|
||||
if err := h.db.Preload("Seller").Preload("Tags").Preload("Reviews").Preload("Reviews.Reviewer").First(&item, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Marketplace item not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch marketplace item"})
|
||||
return
|
||||
}
|
||||
|
||||
// Increment view count
|
||||
h.db.Model(&item).UpdateColumn("view_count", gorm.Expr("view_count + 1"))
|
||||
h.db.Model(&item).Update("last_viewed_at", time.Now())
|
||||
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// CreateMarketplaceItem creates a new marketplace item
|
||||
func (h *MarketplaceHandler) CreateMarketplaceItem(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
var item models.MarketplaceItem
|
||||
|
||||
if err := c.ShouldBindJSON(&item); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
item.SellerID = userID
|
||||
item.Status = "draft" // Items start as draft and need approval
|
||||
|
||||
if err := h.db.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create marketplace item"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, item)
|
||||
}
|
||||
|
||||
// UpdateMarketplaceItem updates an existing marketplace item
|
||||
func (h *MarketplaceHandler) UpdateMarketplaceItem(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
userID := c.GetUint("user_id")
|
||||
var item models.MarketplaceItem
|
||||
|
||||
if err := h.db.First(&item, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Marketplace item not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is the seller
|
||||
if item.SellerID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "You can only update your own items"})
|
||||
return
|
||||
}
|
||||
|
||||
var updateData models.MarketplaceItem
|
||||
if err := c.ShouldBindJSON(&updateData); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update allowed fields
|
||||
item.Title = updateData.Title
|
||||
item.Description = updateData.Description
|
||||
item.Category = updateData.Category
|
||||
item.ContentType = updateData.ContentType
|
||||
item.ContentURL = updateData.ContentURL
|
||||
item.PreviewURL = updateData.PreviewURL
|
||||
item.Thumbnail = updateData.Thumbnail
|
||||
item.Price = updateData.Price
|
||||
item.Currency = updateData.Currency
|
||||
item.IsFree = updateData.IsFree
|
||||
item.Subscription = updateData.Subscription
|
||||
item.SubscriptionPrice = updateData.SubscriptionPrice
|
||||
item.License = updateData.License
|
||||
item.Version = updateData.Version
|
||||
item.LastUpdated = &time.Time{}
|
||||
*item.LastUpdated = time.Now()
|
||||
|
||||
if err := h.db.Save(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update marketplace item"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// DeleteMarketplaceItem deletes a marketplace item
|
||||
func (h *MarketplaceHandler) DeleteMarketplaceItem(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
userID := c.GetUint("user_id")
|
||||
var item models.MarketplaceItem
|
||||
|
||||
if err := h.db.First(&item, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Marketplace item not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is the seller
|
||||
if item.SellerID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "You can only delete your own items"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Delete(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete marketplace item"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Marketplace item deleted successfully"})
|
||||
}
|
||||
|
||||
// GetMyMarketplaceItems returns current user's marketplace items
|
||||
func (h *MarketplaceHandler) GetMyMarketplaceItems(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
var items []models.MarketplaceItem
|
||||
|
||||
if err := h.db.Preload("Tags").Where("seller_id = ?", userID).Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch your marketplace items"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
// CreateMarketplaceReview creates a new review for a marketplace item
|
||||
func (h *MarketplaceHandler) CreateMarketplaceReview(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
itemID := c.Param("id")
|
||||
|
||||
var review models.MarketplaceReview
|
||||
if err := c.ShouldBindJSON(&review); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if item exists
|
||||
var item models.MarketplaceItem
|
||||
if err := h.db.First(&item, itemID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Marketplace item not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user already reviewed this item
|
||||
var existingReview models.MarketplaceReview
|
||||
if err := h.db.Where("item_id = ? AND reviewer_id = ?", itemID, userID).First(&existingReview).Error; err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "You have already reviewed this item"})
|
||||
return
|
||||
}
|
||||
|
||||
review.ItemID = item.ID
|
||||
review.ReviewerID = userID
|
||||
|
||||
// Start transaction
|
||||
tx := h.db.Begin()
|
||||
|
||||
// Create review
|
||||
if err := tx.Create(&review).Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create review"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update item rating
|
||||
var avgRating float64
|
||||
var reviewCount int64
|
||||
tx.Model(&models.MarketplaceReview{}).Where("item_id = ? AND status = ?", itemID, "published").Select("AVG(rating)").Scan(&avgRating)
|
||||
tx.Model(&models.MarketplaceReview{}).Where("item_id = ? AND status = ?", itemID, "published").Count(&reviewCount)
|
||||
|
||||
tx.Model(&item).Updates(map[string]interface{}{
|
||||
"rating": avgRating,
|
||||
"review_count": reviewCount,
|
||||
})
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create review"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, review)
|
||||
}
|
||||
|
||||
// GetMarketplaceReviews returns reviews for a marketplace item
|
||||
func (h *MarketplaceHandler) GetMarketplaceReviews(c *gin.Context) {
|
||||
itemID := c.Param("id")
|
||||
var reviews []models.MarketplaceReview
|
||||
|
||||
if err := h.db.Preload("Reviewer").Where("item_id = ? AND status = ?", itemID, "published").Find(&reviews).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch reviews"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, reviews)
|
||||
}
|
||||
|
||||
// CreateContentShare creates a new content share link
|
||||
func (h *MarketplaceHandler) CreateContentShare(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
var share models.ContentShare
|
||||
|
||||
if err := c.ShouldBindJSON(&share); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
share.OwnerID = userID
|
||||
share.ShareURL = "/shared/" + share.ShareToken
|
||||
|
||||
if err := h.db.Create(&share).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create content share"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, share)
|
||||
}
|
||||
|
||||
// GetContentShare returns a shared content by token
|
||||
func (h *MarketplaceHandler) GetContentShare(c *gin.Context) {
|
||||
token := c.Param("token")
|
||||
var share models.ContentShare
|
||||
|
||||
if err := h.db.Preload("Owner").Where("share_token = ? AND is_active = ?", token, true).First(&share).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Shared content not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if share has expired
|
||||
if share.ExpiresAt != nil && share.ExpiresAt.Before(time.Now()) {
|
||||
c.JSON(http.StatusGone, gin.H{"error": "Shared content has expired"})
|
||||
return
|
||||
}
|
||||
|
||||
// Increment view count
|
||||
h.db.Model(&share).UpdateColumn("view_count", gorm.Expr("view_count + 1"))
|
||||
h.db.Model(&share).Update("last_accessed_at", time.Now())
|
||||
|
||||
// Get the actual content based on content type
|
||||
var content interface{}
|
||||
switch share.ContentType {
|
||||
case "bookmark":
|
||||
var bookmark models.Bookmark
|
||||
if err := h.db.Where("id = ? AND user_id = ?", share.ContentID, share.OwnerID).First(&bookmark).Error; err == nil {
|
||||
content = bookmark
|
||||
}
|
||||
case "note":
|
||||
var note models.Note
|
||||
if err := h.db.Where("id = ? AND user_id = ?", share.ContentID, share.OwnerID).First(¬e).Error; err == nil {
|
||||
content = note
|
||||
}
|
||||
case "file":
|
||||
var file models.File
|
||||
if err := h.db.Where("id = ? AND user_id = ?", share.ContentID, share.OwnerID).First(&file).Error; err == nil {
|
||||
content = file
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"share": share,
|
||||
"content": content,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyContentShares returns current user's content shares
|
||||
func (h *MarketplaceHandler) GetMyContentShares(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
var shares []models.ContentShare
|
||||
|
||||
if err := h.db.Where("owner_id = ?", userID).Find(&shares).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch your content shares"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, shares)
|
||||
}
|
||||
|
||||
// DeleteContentShare deletes a content share
|
||||
func (h *MarketplaceHandler) DeleteContentShare(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
userID := c.GetUint("user_id")
|
||||
var share models.ContentShare
|
||||
|
||||
if err := h.db.First(&share, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Content share not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is the owner
|
||||
if share.OwnerID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "You can only delete your own shares"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Delete(&share).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete content share"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Content share deleted successfully"})
|
||||
}
|
||||
|
||||
// GetMarketplaceStats returns marketplace statistics
|
||||
func (h *MarketplaceHandler) GetMarketplaceStats(c *gin.Context) {
|
||||
var stats struct {
|
||||
TotalItems int64 `json:"total_items"`
|
||||
TotalSellers int64 `json:"total_sellers"`
|
||||
TotalBuyers int64 `json:"total_buyers"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
AverageRating float64 `json:"average_rating"`
|
||||
TotalReviews int64 `json:"total_reviews"`
|
||||
TotalDownloads int64 `json:"total_downloads"`
|
||||
}
|
||||
|
||||
h.db.Model(&models.MarketplaceItem{}).Where("status = ? AND is_approved = ?", "published", true).Count(&stats.TotalItems)
|
||||
h.db.Model(&models.MarketplaceItem{}).Select("COUNT(DISTINCT seller_id)").Row().Scan(&stats.TotalSellers)
|
||||
h.db.Model(&models.MarketplacePurchase{}).Select("COUNT(DISTINCT buyer_id)").Row().Scan(&stats.TotalBuyers)
|
||||
h.db.Model(&models.MarketplacePurchase{}).Where("status = ?", "completed").Select("COALESCE(SUM(price), 0)").Row().Scan(&stats.TotalRevenue)
|
||||
h.db.Model(&models.MarketplaceItem{}).Where("status = ? AND is_approved = ?", "published", true).Select("COALESCE(AVG(rating), 0)").Row().Scan(&stats.AverageRating)
|
||||
h.db.Model(&models.MarketplaceReview{}).Where("status = ?", "published").Count(&stats.TotalReviews)
|
||||
h.db.Model(&models.MarketplaceItem{}).Select("COALESCE(SUM(download_count), 0)").Row().Scan(&stats.TotalDownloads)
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MemberHandler handles member-related requests
|
||||
type MemberHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewMemberHandler creates a new member handler
|
||||
func NewMemberHandler(db *gorm.DB) *MemberHandler {
|
||||
return &MemberHandler{db: db}
|
||||
}
|
||||
|
||||
// GetMembers returns all members
|
||||
func (h *MemberHandler) GetMembers(c *gin.Context) {
|
||||
var users []models.User
|
||||
|
||||
// Get pagination parameters
|
||||
page, _ := strconv.Atoi(c.Query("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.Query("limit"))
|
||||
if limit < 1 {
|
||||
limit = 50
|
||||
}
|
||||
offset := (page - 1) * limit
|
||||
|
||||
// Count total users
|
||||
var total int64
|
||||
h.db.Model(&models.User{}).Count(&total)
|
||||
|
||||
// Get users with pagination
|
||||
if err := h.db.Offset(offset).Limit(limit).Order("created_at DESC").Find(&users).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch members"})
|
||||
return
|
||||
}
|
||||
|
||||
// Transform users to member response format
|
||||
members := make([]map[string]interface{}, len(users))
|
||||
for i, user := range users {
|
||||
members[i] = map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"name": user.FullName,
|
||||
"email": user.Email,
|
||||
"username": user.Username,
|
||||
"role": "Member", // Default role, you might want to add role field to User model
|
||||
"avatar": getInitials(user.FullName),
|
||||
"joinedAt": formatTime(user.CreatedAt),
|
||||
"theme": user.Theme,
|
||||
"language": user.Language,
|
||||
}
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"members": members,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetMemberStats returns member statistics
|
||||
func (h *MemberHandler) GetMemberStats(c *gin.Context) {
|
||||
var totalUsers int64
|
||||
var activeUsers int64 // Users who joined in last 30 days
|
||||
var newUsersThisMonth int64
|
||||
|
||||
// Total users
|
||||
h.db.Model(&models.User{}).Count(&totalUsers)
|
||||
|
||||
// Active users (last 30 days)
|
||||
thirtyDaysAgo := time.Now().AddDate(0, 0, -30)
|
||||
h.db.Model(&models.User{}).Where("updated_at >= ?", thirtyDaysAgo).Count(&activeUsers)
|
||||
|
||||
// New users this month
|
||||
now := time.Now()
|
||||
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
h.db.Model(&models.User{}).Where("created_at >= ?", startOfMonth).Count(&newUsersThisMonth)
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"totalUsers": totalUsers,
|
||||
"activeUsers": activeUsers,
|
||||
"newUsersThisMonth": newUsersThisMonth,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func getInitials(name string) string {
|
||||
if name == "" {
|
||||
return "U"
|
||||
}
|
||||
|
||||
// Simple initials extraction - you might want to improve this
|
||||
parts := strings.Fields(name)
|
||||
if len(parts) >= 2 {
|
||||
return strings.ToUpper(string(parts[0][0]) + string(parts[1][0]))
|
||||
}
|
||||
return strings.ToUpper(string(name[0]))
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
duration := time.Since(t)
|
||||
days := int(duration.Hours() / 24)
|
||||
|
||||
if days == 0 {
|
||||
return "Today"
|
||||
} else if days == 1 {
|
||||
return "Yesterday"
|
||||
} else if days < 7 {
|
||||
return strconv.Itoa(days) + " days ago"
|
||||
} else if days < 30 {
|
||||
weeks := days / 7
|
||||
return strconv.Itoa(weeks) + " week" + pluralS(weeks) + " ago"
|
||||
} else if days < 365 {
|
||||
months := days / 30
|
||||
return strconv.Itoa(months) + " month" + pluralS(months) + " ago"
|
||||
} else {
|
||||
years := days / 365
|
||||
return strconv.Itoa(years) + " year" + pluralS(years) + " ago"
|
||||
}
|
||||
}
|
||||
|
||||
func pluralS(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/services"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PerformanceHandler struct {
|
||||
db *gorm.DB
|
||||
performanceService *services.PerformanceService
|
||||
}
|
||||
|
||||
func NewPerformanceHandler(db *gorm.DB) *PerformanceHandler {
|
||||
return &PerformanceHandler{
|
||||
db: db,
|
||||
performanceService: services.NewPerformanceService(db),
|
||||
}
|
||||
}
|
||||
|
||||
// OptimizeDatabase performs database optimizations
|
||||
func (h *PerformanceHandler) OptimizeDatabase(c *gin.Context) {
|
||||
if err := h.performanceService.OptimizeDatabase(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to optimize database", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Database optimization completed successfully"})
|
||||
}
|
||||
|
||||
// GetDatabaseStats returns database performance statistics
|
||||
func (h *PerformanceHandler) GetDatabaseStats(c *gin.Context) {
|
||||
stats, err := h.performanceService.GetDatabaseStats()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get database stats", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// MonitorPerformance monitors system performance
|
||||
func (h *PerformanceHandler) MonitorPerformance(c *gin.Context) {
|
||||
stats, err := h.performanceService.MonitorPerformance()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to monitor performance", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// CleanupOldAuditLogs removes old audit logs
|
||||
func (h *PerformanceHandler) CleanupOldAuditLogs(c *gin.Context) {
|
||||
retentionDaysStr := c.DefaultQuery("retention_days", "90")
|
||||
retentionDays, err := strconv.Atoi(retentionDaysStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid retention_days parameter"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.performanceService.CleanupOldAuditLogs(retentionDays); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to cleanup audit logs", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Audit logs cleanup completed successfully"})
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// SavedSearchRequest represents the request payload for creating/updating saved searches
|
||||
type SavedSearchRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Query string `json:"query" binding:"required"`
|
||||
Filters map[string]interface{} `json:"filters"`
|
||||
Alert bool `json:"alert"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
// SavedSearchResponse represents the response payload for saved searches
|
||||
type SavedSearchResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Query string `json:"query"`
|
||||
Filters map[string]interface{} `json:"filters"`
|
||||
Alert bool `json:"alert"`
|
||||
LastRun *time.Time `json:"last_run"`
|
||||
RunCount int `json:"run_count"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
Description string `json:"description"`
|
||||
Tags []models.SavedSearchTag `json:"tags"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateSavedSearch handles POST /api/v1/search/saved
|
||||
func CreateSavedSearch(c *gin.Context) {
|
||||
var req SavedSearchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize filters to JSON
|
||||
filtersJSON, err := json.Marshal(req.Filters)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid filters format"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create saved search
|
||||
savedSearch := models.SavedSearch{
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Query: req.Query,
|
||||
Filters: string(filtersJSON),
|
||||
Alert: req.Alert,
|
||||
IsPublic: req.IsPublic,
|
||||
RunCount: 0,
|
||||
Tags: []models.SavedSearchTag{},
|
||||
}
|
||||
|
||||
// Handle tags
|
||||
if len(req.Tags) > 0 {
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
for _, tagName := range req.Tags {
|
||||
var tag models.SavedSearchTag
|
||||
if err := db.Where("name = ?", tagName).First(&tag).Error; err != nil {
|
||||
// Create new tag if it doesn't exist
|
||||
tag = models.SavedSearchTag{
|
||||
Name: tagName,
|
||||
Color: "#3b82f6", // Default blue color
|
||||
}
|
||||
db.Create(&tag)
|
||||
}
|
||||
savedSearch.Tags = append(savedSearch.Tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
if err := db.Create(&savedSearch).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create saved search"})
|
||||
return
|
||||
}
|
||||
|
||||
// Load tags for response
|
||||
db.Preload("Tags").First(&savedSearch, savedSearch.ID)
|
||||
|
||||
response := SavedSearchResponse{
|
||||
ID: savedSearch.ID,
|
||||
Name: savedSearch.Name,
|
||||
Query: savedSearch.Query,
|
||||
Alert: savedSearch.Alert,
|
||||
LastRun: savedSearch.LastRun,
|
||||
RunCount: savedSearch.RunCount,
|
||||
IsPublic: savedSearch.IsPublic,
|
||||
Description: savedSearch.Description,
|
||||
Tags: savedSearch.Tags,
|
||||
CreatedAt: savedSearch.CreatedAt,
|
||||
UpdatedAt: savedSearch.UpdatedAt,
|
||||
}
|
||||
|
||||
// Parse filters back to map
|
||||
json.Unmarshal([]byte(savedSearch.Filters), &response.Filters)
|
||||
|
||||
c.JSON(http.StatusCreated, response)
|
||||
}
|
||||
|
||||
// GetUserSavedSearches handles GET /api/v1/search/saved
|
||||
func GetUserSavedSearches(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
|
||||
// Parse query parameters
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
tagFilter := c.Query("tag")
|
||||
alertFilter := c.Query("alert")
|
||||
|
||||
offset := (page - 1) * limit
|
||||
|
||||
query := db.Model(&models.SavedSearch{}).Where("user_id = ? OR is_public = ?", userID, true)
|
||||
|
||||
// Apply filters
|
||||
if tagFilter != "" {
|
||||
query = query.Joins("JOIN saved_search_tags ON saved_search_tags.id = saved_searches.id").
|
||||
Joins("JOIN saved_search_tag_saved_searches ON saved_search_tag_saved_searches.saved_search_id = saved_searches.id").
|
||||
Joins("JOIN saved_search_tags t ON t.id = saved_search_tag_saved_searches.saved_search_tag_id").
|
||||
Where("t.name = ?", tagFilter)
|
||||
}
|
||||
|
||||
if alertFilter == "true" {
|
||||
query = query.Where("alert = ?", true)
|
||||
} else if alertFilter == "false" {
|
||||
query = query.Where("alert = ?", false)
|
||||
}
|
||||
|
||||
var savedSearches []models.SavedSearch
|
||||
var total int64
|
||||
|
||||
if err := query.Preload("Tags").Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to count saved searches"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := query.Preload("Tags").Offset(offset).Limit(limit).Order("created_at DESC").Find(&savedSearches).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved searches"})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
var responses []SavedSearchResponse
|
||||
for _, ss := range savedSearches {
|
||||
var filters map[string]interface{}
|
||||
json.Unmarshal([]byte(ss.Filters), &filters)
|
||||
|
||||
response := SavedSearchResponse{
|
||||
ID: ss.ID,
|
||||
Name: ss.Name,
|
||||
Query: ss.Query,
|
||||
Filters: filters,
|
||||
Alert: ss.Alert,
|
||||
LastRun: ss.LastRun,
|
||||
RunCount: ss.RunCount,
|
||||
IsPublic: ss.IsPublic,
|
||||
Description: ss.Description,
|
||||
Tags: ss.Tags,
|
||||
CreatedAt: ss.CreatedAt,
|
||||
UpdatedAt: ss.UpdatedAt,
|
||||
}
|
||||
responses = append(responses, response)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"saved_searches": responses,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// GetSavedSearch handles GET /api/v1/search/saved/:id
|
||||
func GetSavedSearch(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid saved search ID"})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
var savedSearch models.SavedSearch
|
||||
|
||||
if err := db.Preload("Tags").Where("id = ? AND (user_id = ? OR is_public = ?)", id, userID, true).First(&savedSearch).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Saved search not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved search"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var filters map[string]interface{}
|
||||
json.Unmarshal([]byte(savedSearch.Filters), &filters)
|
||||
|
||||
response := SavedSearchResponse{
|
||||
ID: savedSearch.ID,
|
||||
Name: savedSearch.Name,
|
||||
Query: savedSearch.Query,
|
||||
Filters: filters,
|
||||
Alert: savedSearch.Alert,
|
||||
LastRun: savedSearch.LastRun,
|
||||
RunCount: savedSearch.RunCount,
|
||||
IsPublic: savedSearch.IsPublic,
|
||||
Description: savedSearch.Description,
|
||||
Tags: savedSearch.Tags,
|
||||
CreatedAt: savedSearch.CreatedAt,
|
||||
UpdatedAt: savedSearch.UpdatedAt,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// UpdateSavedSearch handles PUT /api/v1/search/saved/:id
|
||||
func UpdateSavedSearch(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid saved search ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req SavedSearchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
var savedSearch models.SavedSearch
|
||||
|
||||
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&savedSearch).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Saved search not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved search"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Update fields
|
||||
savedSearch.Name = req.Name
|
||||
savedSearch.Query = req.Query
|
||||
savedSearch.Alert = req.Alert
|
||||
savedSearch.IsPublic = req.IsPublic
|
||||
savedSearch.Description = req.Description
|
||||
|
||||
// Update filters
|
||||
filtersJSON, err := json.Marshal(req.Filters)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid filters format"})
|
||||
return
|
||||
}
|
||||
savedSearch.Filters = string(filtersJSON)
|
||||
|
||||
// Update tags
|
||||
if err := db.Model(&savedSearch).Association("Tags").Clear(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear tags"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, tagName := range req.Tags {
|
||||
var tag models.SavedSearchTag
|
||||
if err := db.Where("name = ?", tagName).First(&tag).Error; err != nil {
|
||||
tag = models.SavedSearchTag{
|
||||
Name: tagName,
|
||||
Color: "#3b82f6",
|
||||
}
|
||||
db.Create(&tag)
|
||||
}
|
||||
if err := db.Model(&savedSearch).Association("Tags").Append(&tag); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add tag"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Save(&savedSearch).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update saved search"})
|
||||
return
|
||||
}
|
||||
|
||||
// Load updated data
|
||||
db.Preload("Tags").First(&savedSearch, savedSearch.ID)
|
||||
|
||||
var filters map[string]interface{}
|
||||
json.Unmarshal([]byte(savedSearch.Filters), &filters)
|
||||
|
||||
response := SavedSearchResponse{
|
||||
ID: savedSearch.ID,
|
||||
Name: savedSearch.Name,
|
||||
Query: savedSearch.Query,
|
||||
Filters: filters,
|
||||
Alert: savedSearch.Alert,
|
||||
LastRun: savedSearch.LastRun,
|
||||
RunCount: savedSearch.RunCount,
|
||||
IsPublic: savedSearch.IsPublic,
|
||||
Description: savedSearch.Description,
|
||||
Tags: savedSearch.Tags,
|
||||
CreatedAt: savedSearch.CreatedAt,
|
||||
UpdatedAt: savedSearch.UpdatedAt,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// DeleteSavedSearch handles DELETE /api/v1/search/saved/:id
|
||||
func DeleteSavedSearch(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid saved search ID"})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
result := db.Where("id = ? AND user_id = ?", id, userID).Delete(&models.SavedSearch{})
|
||||
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete saved search"})
|
||||
return
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Saved search not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Saved search deleted successfully"})
|
||||
}
|
||||
|
||||
// RunSavedSearch handles POST /api/v1/search/saved/:id/run
|
||||
func RunSavedSearch(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid saved search ID"})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
var savedSearch models.SavedSearch
|
||||
|
||||
if err := db.Where("id = ? AND (user_id = ? OR is_public = ?)", id, userID, true).First(&savedSearch).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Saved search not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved search"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Parse filters
|
||||
var filters map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(savedSearch.Filters), &filters); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse filters"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create search request based on saved search
|
||||
searchReq := map[string]interface{}{
|
||||
"query": savedSearch.Query,
|
||||
}
|
||||
|
||||
// Merge filters
|
||||
for k, v := range filters {
|
||||
searchReq[k] = v
|
||||
}
|
||||
|
||||
// Perform the search using existing enhanced search logic
|
||||
// This is a simplified version - in production, you'd want to reuse the actual search handler
|
||||
searchResults, err := performSearchFromSavedSearch(searchReq, userID, db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to execute search"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update saved search run statistics
|
||||
now := time.Now()
|
||||
savedSearch.LastRun = &now
|
||||
savedSearch.RunCount++
|
||||
db.Save(&savedSearch)
|
||||
|
||||
// Log search analytics
|
||||
logSearchAnalytics(userID, savedSearch.Query, savedSearch.Filters, len(searchResults), db)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"results": searchResults,
|
||||
"query": savedSearch.Query,
|
||||
"filters": filters,
|
||||
"total": len(searchResults),
|
||||
"saved_search": gin.H{
|
||||
"id": savedSearch.ID,
|
||||
"name": savedSearch.Name,
|
||||
"last_run": savedSearch.LastRun,
|
||||
"run_count": savedSearch.RunCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetSavedSearchTags handles GET /api/v1/search/saved/tags
|
||||
func GetSavedSearchTags(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
var tags []models.SavedSearchTag
|
||||
|
||||
if err := db.Order("name").Find(&tags).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tags"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"tags": tags})
|
||||
}
|
||||
|
||||
// Helper function to perform search from saved search
|
||||
func performSearchFromSavedSearch(searchReq map[string]interface{}, userID uint, db *gorm.DB) ([]interface{}, error) {
|
||||
// Build search filters from the request
|
||||
filters := SearchFilters{
|
||||
Query: getStringValue(searchReq, "query"),
|
||||
ContentType: getStringValue(searchReq, "content_type"),
|
||||
Limit: getIntValue(searchReq, "limit", 20),
|
||||
Offset: getIntValue(searchReq, "offset", 0),
|
||||
}
|
||||
|
||||
// Parse tags if present
|
||||
if tags, ok := searchReq["tags"].([]interface{}); ok {
|
||||
for _, tag := range tags {
|
||||
if tagStr, ok := tag.(string); ok {
|
||||
filters.Tags = append(filters.Tags, tagStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse date range if present
|
||||
if dateRange, ok := searchReq["date_range"].(map[string]interface{}); ok {
|
||||
if startStr, ok := dateRange["start"].(string); ok && startStr != "" {
|
||||
if startTime, err := time.Parse("2006-01-02", startStr); err == nil {
|
||||
filters.DateRange.Start = startTime
|
||||
}
|
||||
}
|
||||
if endStr, ok := dateRange["end"].(string); ok && endStr != "" {
|
||||
if endTime, err := time.Parse("2006-01-02", endStr); err == nil {
|
||||
filters.DateRange.End = endTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse boolean filters
|
||||
if isFavorite, ok := searchReq["is_favorite"].(bool); ok {
|
||||
filters.IsFavorite = &isFavorite
|
||||
}
|
||||
if isRead, ok := searchReq["is_read"].(bool); ok {
|
||||
filters.IsRead = &isRead
|
||||
}
|
||||
if isPublic, ok := searchReq["is_public"].(bool); ok {
|
||||
filters.IsPublic = &isPublic
|
||||
}
|
||||
|
||||
// Perform the search using existing enhanced search logic
|
||||
results, err := performEnhancedSearch(filters, userID, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert results to interface slice
|
||||
var interfaceResults []interface{}
|
||||
for _, result := range results {
|
||||
interfaceResults = append(interfaceResults, result)
|
||||
}
|
||||
|
||||
return interfaceResults, nil
|
||||
}
|
||||
|
||||
// Helper function to perform enhanced search (reused from search_enhanced.go)
|
||||
func performEnhancedSearch(filters SearchFilters, userID uint, db *gorm.DB) ([]SearchResult, error) {
|
||||
var results []SearchResult
|
||||
|
||||
// Search bookmarks
|
||||
if filters.ContentType == "all" || filters.ContentType == "bookmarks" {
|
||||
var bookmarks []models.Bookmark
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
// Apply text search
|
||||
if filters.Query != "" {
|
||||
query = query.Where("title ILIKE ? OR description ILIKE ? OR content ILIKE ?",
|
||||
"%"+filters.Query+"%", "%"+filters.Query+"%", "%"+filters.Query+"%")
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
if filters.IsFavorite != nil {
|
||||
query = query.Where("is_favorite = ?", *filters.IsFavorite)
|
||||
}
|
||||
|
||||
if err := query.Limit(filters.Limit).Offset(filters.Offset).Find(&bookmarks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, bookmark := range bookmarks {
|
||||
result := SearchResult{
|
||||
ID: bookmark.ID,
|
||||
Type: "bookmark",
|
||||
Title: bookmark.Title,
|
||||
Description: bookmark.Description,
|
||||
Content: bookmark.Content,
|
||||
CreatedAt: bookmark.CreatedAt,
|
||||
UpdatedAt: bookmark.UpdatedAt,
|
||||
URL: bookmark.URL,
|
||||
IsFavorite: bookmark.IsFavorite,
|
||||
IsRead: bookmark.IsRead,
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Search tasks
|
||||
if filters.ContentType == "all" || filters.ContentType == "tasks" {
|
||||
var tasks []models.Task
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
if filters.Query != "" {
|
||||
query = query.Where("title ILIKE ? OR description ILIKE ?",
|
||||
"%"+filters.Query+"%", "%"+filters.Query+"%")
|
||||
}
|
||||
|
||||
if err := query.Limit(filters.Limit).Offset(filters.Offset).Find(&tasks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
result := SearchResult{
|
||||
ID: task.ID,
|
||||
Type: "task",
|
||||
Title: task.Title,
|
||||
Description: task.Description,
|
||||
CreatedAt: task.CreatedAt,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
Status: string(task.Status),
|
||||
Priority: string(task.Priority),
|
||||
DueDate: task.DueDate,
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Search notes
|
||||
if filters.ContentType == "all" || filters.ContentType == "notes" {
|
||||
var notes []models.Note
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
if filters.Query != "" {
|
||||
query = query.Where("title ILIKE ? OR content ILIKE ?",
|
||||
"%"+filters.Query+"%", "%"+filters.Query+"%")
|
||||
}
|
||||
|
||||
if err := query.Limit(filters.Limit).Offset(filters.Offset).Find(¬es).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, note := range notes {
|
||||
result := SearchResult{
|
||||
ID: note.ID,
|
||||
Type: "note",
|
||||
Title: note.Title,
|
||||
Description: note.Content[:min(200, len(note.Content))],
|
||||
Content: note.Content,
|
||||
CreatedAt: note.CreatedAt,
|
||||
UpdatedAt: note.UpdatedAt,
|
||||
IsPublic: note.IsPublic,
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func getStringValue(m map[string]interface{}, key string) string {
|
||||
if val, ok := m[key].(string); ok {
|
||||
return val
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getIntValue(m map[string]interface{}, key string, defaultValue int) int {
|
||||
if val, ok := m[key].(float64); ok {
|
||||
return int(val)
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Helper function to log search analytics
|
||||
func logSearchAnalytics(userID uint, query string, filters string, resultsCount int, db *gorm.DB) {
|
||||
analytics := models.SearchAnalytics{
|
||||
UserID: userID,
|
||||
Query: query,
|
||||
Filters: filters,
|
||||
ResultsCount: resultsCount,
|
||||
Took: 0, // Would be measured in actual implementation
|
||||
ContentType: "mixed",
|
||||
}
|
||||
|
||||
db.Create(&analytics)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// BraveSearchResponse represents the response from Brave Search API
|
||||
type BraveSearchResponse struct {
|
||||
Mixed struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
} `json:"mixed"`
|
||||
Web struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
} `json:"web"`
|
||||
Query struct {
|
||||
Original string `json:"original"`
|
||||
Display string `json:"display"`
|
||||
} `json:"query"`
|
||||
}
|
||||
|
||||
type BraveNewsResponse struct {
|
||||
News struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
} `json:"news"`
|
||||
Query struct {
|
||||
Original string `json:"original"`
|
||||
Display string `json:"display"`
|
||||
} `json:"query"`
|
||||
}
|
||||
|
||||
// BraveSearchResult represents a normalized search result returned to the frontend
|
||||
// Note: Brave's API uses fields like "page_age"; we normalize this to "published_date"
|
||||
// to match the BrowserSearch UI expectations.
|
||||
type BraveSearchResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
PublishedDate string `json:"published_date,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// SearchWeb handles POST /api/v1/search/web
|
||||
func SearchWeb(c *gin.Context) {
|
||||
var req struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set default count if not provided
|
||||
if req.Count == 0 {
|
||||
req.Count = 10
|
||||
}
|
||||
|
||||
apiKey := os.Getenv("BRAVE_API_KEY")
|
||||
if apiKey == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Brave API key not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build Brave Search API request
|
||||
baseURL := "https://api.search.brave.com/res/v1/web/search"
|
||||
q := url.Values{}
|
||||
q.Set("q", req.Query)
|
||||
q.Set("count", fmt.Sprint(req.Count))
|
||||
endpoint := fmt.Sprintf("%s?%s", baseURL, q.Encode())
|
||||
|
||||
reqHTTP, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Brave request"})
|
||||
return
|
||||
}
|
||||
reqHTTP.Header.Set("Accept", "application/json")
|
||||
reqHTTP.Header.Set("X-Subscription-Token", apiKey)
|
||||
|
||||
resp, err := http.DefaultClient.Do(reqHTTP)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave Search API"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave API error: %d", resp.StatusCode)})
|
||||
return
|
||||
}
|
||||
|
||||
var braveResp BraveSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&braveResp); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave response"})
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer web.results, fall back to mixed.results
|
||||
resultsRaw := braveResp.Web.Results
|
||||
if len(resultsRaw) == 0 {
|
||||
resultsRaw = braveResp.Mixed.Results
|
||||
}
|
||||
|
||||
results := make([]BraveSearchResult, 0, len(resultsRaw))
|
||||
for _, r := range resultsRaw {
|
||||
title, _ := r["title"].(string)
|
||||
urlStr, _ := r["url"].(string)
|
||||
desc, _ := r["description"].(string)
|
||||
lang, _ := r["language"].(string)
|
||||
pageAge, _ := r["page_age"].(string)
|
||||
|
||||
results = append(results, BraveSearchResult{
|
||||
Title: title,
|
||||
URL: urlStr,
|
||||
Description: desc,
|
||||
PublishedDate: pageAge,
|
||||
Language: lang,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"results": results,
|
||||
"query": gin.H{
|
||||
"original": braveResp.Query.Original,
|
||||
"display": braveResp.Query.Display,
|
||||
},
|
||||
"count": len(results),
|
||||
})
|
||||
}
|
||||
|
||||
func SearchNews(c *gin.Context) {
|
||||
var req struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Count == 0 {
|
||||
req.Count = 10
|
||||
}
|
||||
|
||||
apiKey := os.Getenv("BRAVE_API_KEY")
|
||||
if apiKey == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Brave API key not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := "https://api.search.brave.com/res/v1/news/search"
|
||||
q := url.Values{}
|
||||
q.Set("q", req.Query)
|
||||
q.Set("count", fmt.Sprint(req.Count))
|
||||
endpoint := fmt.Sprintf("%s?%s", baseURL, q.Encode())
|
||||
|
||||
reqHTTP, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Brave request"})
|
||||
return
|
||||
}
|
||||
reqHTTP.Header.Set("Accept", "application/json")
|
||||
reqHTTP.Header.Set("X-Subscription-Token", apiKey)
|
||||
|
||||
resp, err := http.DefaultClient.Do(reqHTTP)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave News API"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave News API error: %d", resp.StatusCode)})
|
||||
return
|
||||
}
|
||||
|
||||
var braveResp BraveNewsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&braveResp); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave news response"})
|
||||
return
|
||||
}
|
||||
|
||||
resultsRaw := braveResp.News.Results
|
||||
results := make([]BraveSearchResult, 0, len(resultsRaw))
|
||||
for _, r := range resultsRaw {
|
||||
title, _ := r["title"].(string)
|
||||
urlStr, _ := r["url"].(string)
|
||||
desc, _ := r["description"].(string)
|
||||
lang, _ := r["language"].(string)
|
||||
pubDate, _ := r["published_date"].(string)
|
||||
if pubDate == "" {
|
||||
pubDate, _ = r["page_age"].(string)
|
||||
}
|
||||
|
||||
results = append(results, BraveSearchResult{
|
||||
Title: title,
|
||||
URL: urlStr,
|
||||
Description: desc,
|
||||
PublishedDate: pubDate,
|
||||
Language: lang,
|
||||
})
|
||||
}
|
||||
|
||||
original := braveResp.Query.Original
|
||||
display := braveResp.Query.Display
|
||||
if original == "" {
|
||||
original = req.Query
|
||||
}
|
||||
if display == "" {
|
||||
display = req.Query
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"results": results,
|
||||
"query": gin.H{
|
||||
"original": original,
|
||||
"display": display,
|
||||
},
|
||||
"count": len(results),
|
||||
})
|
||||
}
|
||||
|
||||
// GetSearchSuggestions handles GET /api/v1/search/suggestions
|
||||
func GetSearchSuggestions(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// For now, return empty suggestions
|
||||
// In a real implementation, you might want to implement autocomplete
|
||||
// using Brave's autocomplete API or your own suggestion engine
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"suggestions": []string{},
|
||||
"query": query,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SearchFilters represents the search filters
|
||||
type SearchFilters struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
ContentType string `json:"content_type"` // 'all' | 'bookmarks' | 'tasks' | 'notes' | 'files'
|
||||
Tags []string `json:"tags"`
|
||||
DateRange DateRange `json:"date_range"`
|
||||
Author string `json:"author"`
|
||||
Language string `json:"language"`
|
||||
FileTypes []string `json:"file_types"`
|
||||
IsFavorite *bool `json:"is_favorite"`
|
||||
IsRead *bool `json:"is_read"`
|
||||
IsPublic *bool `json:"is_public"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
type DateRange struct {
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
}
|
||||
|
||||
// SearchResult represents a unified search result
|
||||
type SearchResult struct {
|
||||
ID uint `json:"id"`
|
||||
Type string `json:"type"` // 'bookmark', 'task', 'note', 'file'
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content"`
|
||||
Tags []models.Tag `json:"tags"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
DueDate *time.Time `json:"due_date,omitempty"`
|
||||
IsFavorite bool `json:"is_favorite,omitempty"`
|
||||
IsRead bool `json:"is_read,omitempty"`
|
||||
IsPublic bool `json:"is_public,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
FileSize int64 `json:"file_size,omitempty"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
FileType string `json:"file_type,omitempty"`
|
||||
Progress int `json:"progress,omitempty"`
|
||||
Highights map[string][]string `json:"highlights,omitempty"` // Search highlights
|
||||
Score float64 `json:"score"` // Relevance score
|
||||
}
|
||||
|
||||
// SearchResponse represents the search response
|
||||
type SearchResponse struct {
|
||||
Results []SearchResult `json:"results"`
|
||||
Total int64 `json:"total"`
|
||||
Query string `json:"query"`
|
||||
Filters SearchFilters `json:"filters"`
|
||||
Took int64 `json:"took"` // Time taken in milliseconds
|
||||
Suggestions []string `json:"suggestions"` // Search suggestions
|
||||
Aggregations map[string]int `json:"aggregations"` // Content type counts
|
||||
}
|
||||
|
||||
// EnhancedSearch handles POST /api/v1/search/enhanced
|
||||
func EnhancedSearch(c *gin.Context) {
|
||||
var filters SearchFilters
|
||||
if err := c.ShouldBindJSON(&filters); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if filters.ContentType == "" {
|
||||
filters.ContentType = "all"
|
||||
}
|
||||
if filters.Limit == 0 {
|
||||
filters.Limit = 20
|
||||
}
|
||||
if filters.Limit > 100 {
|
||||
filters.Limit = 100
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
db := config.GetDB()
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var results []SearchResult
|
||||
var total int64
|
||||
aggregations := make(map[string]int)
|
||||
|
||||
// Search based on content type
|
||||
switch filters.ContentType {
|
||||
case "bookmarks":
|
||||
results, total = searchBookmarks(db, userID, filters)
|
||||
aggregations["bookmarks"] = int(total)
|
||||
case "tasks":
|
||||
results, total = searchTasks(db, userID, filters)
|
||||
aggregations["tasks"] = int(total)
|
||||
case "notes":
|
||||
results, total = searchNotes(db, userID, filters)
|
||||
aggregations["notes"] = int(total)
|
||||
case "files":
|
||||
results, total = searchFiles(db, userID, filters)
|
||||
aggregations["files"] = int(total)
|
||||
default: // all
|
||||
bookmarkResults, bookmarkTotal := searchBookmarks(db, userID, filters)
|
||||
taskResults, taskTotal := searchTasks(db, userID, filters)
|
||||
noteResults, noteTotal := searchNotes(db, userID, filters)
|
||||
fileResults, fileTotal := searchFiles(db, userID, filters)
|
||||
|
||||
results = append(append(append(bookmarkResults, taskResults...), noteResults...), fileResults...)
|
||||
total = bookmarkTotal + taskTotal + noteTotal + fileTotal
|
||||
|
||||
aggregations["bookmarks"] = int(bookmarkTotal)
|
||||
aggregations["tasks"] = int(taskTotal)
|
||||
aggregations["notes"] = int(noteTotal)
|
||||
aggregations["files"] = int(fileTotal)
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
if filters.Offset > 0 && len(results) > filters.Offset {
|
||||
results = results[filters.Offset:]
|
||||
}
|
||||
if len(results) > filters.Limit {
|
||||
results = results[:filters.Limit]
|
||||
}
|
||||
|
||||
// Get search suggestions
|
||||
suggestions := getSearchSuggestions(db, userID, filters.Query)
|
||||
|
||||
// Calculate time taken
|
||||
took := time.Since(startTime).Milliseconds()
|
||||
|
||||
response := SearchResponse{
|
||||
Results: results,
|
||||
Total: total,
|
||||
Query: filters.Query,
|
||||
Filters: filters,
|
||||
Took: took,
|
||||
Suggestions: suggestions,
|
||||
Aggregations: aggregations,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// searchBookmarks searches bookmarks with filters
|
||||
func searchBookmarks(db *gorm.DB, userID uint, filters SearchFilters) ([]SearchResult, int64) {
|
||||
var bookmarks []models.Bookmark
|
||||
var results []SearchResult
|
||||
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
// Text search
|
||||
if filters.Query != "" {
|
||||
searchTerm := "%" + strings.ToLower(filters.Query) + "%"
|
||||
query = query.Where("LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR LOWER(content) LIKE ? OR LOWER(url) LIKE ?",
|
||||
searchTerm, searchTerm, searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if len(filters.Tags) > 0 {
|
||||
query = query.Joins("JOIN bookmark_tags ON bookmarks.id = bookmark_tags.bookmark_id").
|
||||
Joins("JOIN tags ON bookmark_tags.tag_id = tags.id").
|
||||
Where("tags.name IN ?", filters.Tags)
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if !filters.DateRange.Start.IsZero() {
|
||||
query = query.Where("created_at >= ?", filters.DateRange.Start)
|
||||
}
|
||||
if !filters.DateRange.End.IsZero() {
|
||||
query = query.Where("created_at <= ?", filters.DateRange.End)
|
||||
}
|
||||
|
||||
// Boolean filters
|
||||
if filters.IsFavorite != nil {
|
||||
query = query.Where("is_favorite = ?", *filters.IsFavorite)
|
||||
}
|
||||
if filters.IsRead != nil {
|
||||
query = query.Where("is_read = ?", *filters.IsRead)
|
||||
}
|
||||
|
||||
// Author filter
|
||||
if filters.Author != "" {
|
||||
query = query.Where("LOWER(author) LIKE ?", "%"+strings.ToLower(filters.Author)+"%")
|
||||
}
|
||||
|
||||
// Count total
|
||||
var total int64
|
||||
query.Model(&models.Bookmark{}).Count(&total)
|
||||
|
||||
// Get results with tags
|
||||
if err := query.Preload("Tags").Find(&bookmarks).Error; err != nil {
|
||||
return results, 0
|
||||
}
|
||||
|
||||
// Convert to search results
|
||||
for _, bookmark := range bookmarks {
|
||||
result := SearchResult{
|
||||
ID: bookmark.ID,
|
||||
Type: "bookmark",
|
||||
Title: bookmark.Title,
|
||||
Description: bookmark.Description,
|
||||
Content: bookmark.Content,
|
||||
Tags: bookmark.Tags,
|
||||
CreatedAt: bookmark.CreatedAt,
|
||||
UpdatedAt: bookmark.UpdatedAt,
|
||||
URL: bookmark.URL,
|
||||
IsFavorite: bookmark.IsFavorite,
|
||||
IsRead: bookmark.IsRead,
|
||||
Author: bookmark.Author,
|
||||
Score: calculateRelevanceScore(filters.Query, bookmark.Title, bookmark.Description, bookmark.Content),
|
||||
}
|
||||
|
||||
if bookmark.PublishedAt != nil {
|
||||
result.DueDate = bookmark.PublishedAt // Using DueDate field for published date
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, total
|
||||
}
|
||||
|
||||
// searchTasks searches tasks with filters
|
||||
func searchTasks(db *gorm.DB, userID uint, filters SearchFilters) ([]SearchResult, int64) {
|
||||
var tasks []models.Task
|
||||
var results []SearchResult
|
||||
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
// Text search
|
||||
if filters.Query != "" {
|
||||
searchTerm := "%" + strings.ToLower(filters.Query) + "%"
|
||||
query = query.Where("LOWER(title) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if len(filters.Tags) > 0 {
|
||||
query = query.Joins("JOIN task_tags ON tasks.id = task_tags.task_id").
|
||||
Joins("JOIN tags ON task_tags.tag_id = tags.id").
|
||||
Where("tags.name IN ?", filters.Tags)
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if !filters.DateRange.Start.IsZero() {
|
||||
query = query.Where("created_at >= ?", filters.DateRange.Start)
|
||||
}
|
||||
if !filters.DateRange.End.IsZero() {
|
||||
query = query.Where("created_at <= ?", filters.DateRange.End)
|
||||
}
|
||||
|
||||
// Count total
|
||||
var total int64
|
||||
query.Model(&models.Task{}).Count(&total)
|
||||
|
||||
// Get results with tags
|
||||
if err := query.Preload("Tags").Find(&tasks).Error; err != nil {
|
||||
return results, 0
|
||||
}
|
||||
|
||||
// Convert to search results
|
||||
for _, task := range tasks {
|
||||
result := SearchResult{
|
||||
ID: task.ID,
|
||||
Type: "task",
|
||||
Title: task.Title,
|
||||
Description: task.Description,
|
||||
Tags: task.Tags,
|
||||
CreatedAt: task.CreatedAt,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
Status: string(task.Status),
|
||||
Priority: string(task.Priority),
|
||||
DueDate: task.DueDate,
|
||||
Progress: task.Progress,
|
||||
Score: calculateRelevanceScore(filters.Query, task.Title, task.Description, ""),
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, total
|
||||
}
|
||||
|
||||
// searchNotes searches notes with filters
|
||||
func searchNotes(db *gorm.DB, userID uint, filters SearchFilters) ([]SearchResult, int64) {
|
||||
var notes []models.Note
|
||||
var results []SearchResult
|
||||
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
// Text search
|
||||
if filters.Query != "" {
|
||||
searchTerm := "%" + strings.ToLower(filters.Query) + "%"
|
||||
query = query.Where("LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR LOWER(content) LIKE ?",
|
||||
searchTerm, searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if len(filters.Tags) > 0 {
|
||||
query = query.Joins("JOIN note_tags ON notes.id = note_tags.note_id").
|
||||
Joins("JOIN tags ON note_tags.tag_id = tags.id").
|
||||
Where("tags.name IN ?", filters.Tags)
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if !filters.DateRange.Start.IsZero() {
|
||||
query = query.Where("created_at >= ?", filters.DateRange.Start)
|
||||
}
|
||||
if !filters.DateRange.End.IsZero() {
|
||||
query = query.Where("created_at <= ?", filters.DateRange.End)
|
||||
}
|
||||
|
||||
// Boolean filters
|
||||
if filters.IsPublic != nil {
|
||||
query = query.Where("is_public = ?", *filters.IsPublic)
|
||||
}
|
||||
|
||||
// Count total
|
||||
var total int64
|
||||
query.Model(&models.Note{}).Count(&total)
|
||||
|
||||
// Get results with tags
|
||||
if err := query.Preload("Tags").Find(¬es).Error; err != nil {
|
||||
return results, 0
|
||||
}
|
||||
|
||||
// Convert to search results
|
||||
for _, note := range notes {
|
||||
result := SearchResult{
|
||||
ID: note.ID,
|
||||
Type: "note",
|
||||
Title: note.Title,
|
||||
Description: note.Description,
|
||||
Content: note.Content,
|
||||
Tags: note.Tags,
|
||||
CreatedAt: note.CreatedAt,
|
||||
UpdatedAt: note.UpdatedAt,
|
||||
IsPublic: note.IsPublic,
|
||||
Score: calculateRelevanceScore(filters.Query, note.Title, note.Description, note.Content),
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, total
|
||||
}
|
||||
|
||||
// searchFiles searches files with filters
|
||||
func searchFiles(db *gorm.DB, userID uint, filters SearchFilters) ([]SearchResult, int64) {
|
||||
var files []models.File
|
||||
var results []SearchResult
|
||||
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
// Text search
|
||||
if filters.Query != "" {
|
||||
searchTerm := "%" + strings.ToLower(filters.Query) + "%"
|
||||
query = query.Where("LOWER(original_name) LIKE ? OR LOWER(description) LIKE ? OR LOWER(content) LIKE ?",
|
||||
searchTerm, searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if len(filters.Tags) > 0 {
|
||||
query = query.Joins("JOIN file_tags ON files.id = file_tags.file_id").
|
||||
Joins("JOIN tags ON file_tags.tag_id = tags.id").
|
||||
Where("tags.name IN ?", filters.Tags)
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if !filters.DateRange.Start.IsZero() {
|
||||
query = query.Where("created_at >= ?", filters.DateRange.Start)
|
||||
}
|
||||
if !filters.DateRange.End.IsZero() {
|
||||
query = query.Where("created_at <= ?", filters.DateRange.End)
|
||||
}
|
||||
|
||||
// File type filter
|
||||
if len(filters.FileTypes) > 0 {
|
||||
query = query.Where("file_type IN ?", filters.FileTypes)
|
||||
}
|
||||
|
||||
// Boolean filters
|
||||
if filters.IsPublic != nil {
|
||||
query = query.Where("is_public = ?", *filters.IsPublic)
|
||||
}
|
||||
|
||||
// Count total
|
||||
var total int64
|
||||
query.Model(&models.File{}).Count(&total)
|
||||
|
||||
// Get results with tags
|
||||
if err := query.Preload("Tags").Find(&files).Error; err != nil {
|
||||
return results, 0
|
||||
}
|
||||
|
||||
// Convert to search results
|
||||
for _, file := range files {
|
||||
result := SearchResult{
|
||||
ID: file.ID,
|
||||
Type: "file",
|
||||
Title: file.OriginalName,
|
||||
Description: file.Description,
|
||||
Content: file.Content,
|
||||
Tags: file.Tags,
|
||||
CreatedAt: file.CreatedAt,
|
||||
UpdatedAt: file.UpdatedAt,
|
||||
FileSize: file.FileSize,
|
||||
MimeType: file.MimeType,
|
||||
FileType: string(file.FileType),
|
||||
IsPublic: file.IsPublic,
|
||||
Score: calculateRelevanceScore(filters.Query, file.OriginalName, file.Description, file.Content),
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, total
|
||||
}
|
||||
|
||||
// calculateRelevanceScore calculates a simple relevance score for search results
|
||||
func calculateRelevanceScore(query, title, description, content string) float64 {
|
||||
if query == "" {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
queryLower := strings.ToLower(query)
|
||||
titleLower := strings.ToLower(title)
|
||||
descLower := strings.ToLower(description)
|
||||
contentLower := strings.ToLower(content)
|
||||
|
||||
score := 0.0
|
||||
|
||||
// Title matches are most important
|
||||
if strings.Contains(titleLower, queryLower) {
|
||||
score += 10.0
|
||||
if strings.HasPrefix(titleLower, queryLower) {
|
||||
score += 5.0 // Bonus for prefix match
|
||||
}
|
||||
}
|
||||
|
||||
// Description matches
|
||||
if strings.Contains(descLower, queryLower) {
|
||||
score += 5.0
|
||||
}
|
||||
|
||||
// Content matches
|
||||
if strings.Contains(contentLower, queryLower) {
|
||||
score += 2.0
|
||||
}
|
||||
|
||||
// Word-based scoring
|
||||
queryWords := strings.Fields(queryLower)
|
||||
for _, word := range queryWords {
|
||||
if strings.Contains(titleLower, word) {
|
||||
score += 3.0
|
||||
}
|
||||
if strings.Contains(descLower, word) {
|
||||
score += 1.5
|
||||
}
|
||||
if strings.Contains(contentLower, word) {
|
||||
score += 1.0
|
||||
}
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
// getSearchSuggestions gets search suggestions based on user's search history and popular content
|
||||
func getSearchSuggestions(db *gorm.DB, userID uint, query string) []string {
|
||||
// For now, return empty suggestions
|
||||
// In a future implementation, this could:
|
||||
// - Look at user's search history
|
||||
// - Suggest popular tags
|
||||
// - Suggest based on content titles
|
||||
// - Use AI to generate semantic suggestions
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// SaveSearch handles POST /api/v1/search/save
|
||||
func SaveSearch(c *gin.Context) {
|
||||
var req struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
Filters SearchFilters `json:"filters"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Alert bool `json:"alert"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Implement saved searches functionality
|
||||
// This would require a SavedSearch model
|
||||
|
||||
c.JSON(http.StatusNotImplemented, gin.H{
|
||||
"message": "Saved searches functionality coming soon",
|
||||
})
|
||||
}
|
||||
|
||||
// GetSearchAnalytics handles GET /api/v1/search/analytics
|
||||
func GetSearchAnalytics(c *gin.Context) {
|
||||
// TODO: Implement search analytics
|
||||
// This could include:
|
||||
// - Most searched terms
|
||||
// - Search frequency over time
|
||||
// - Content type distribution
|
||||
// - Popular filters
|
||||
|
||||
c.JSON(http.StatusNotImplemented, gin.H{
|
||||
"message": "Search analytics functionality coming soon",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SemanticSearchRequest represents a semantic search request
|
||||
type SemanticSearchRequest struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
ContentType string `json:"content_type"` // 'all' | 'bookmarks' | 'tasks' | 'notes' | 'files'
|
||||
Limit int `json:"limit"`
|
||||
Threshold float64 `json:"threshold"` // Similarity threshold (0-1)
|
||||
}
|
||||
|
||||
// SemanticSearchResponse represents semantic search response
|
||||
type SemanticSearchResponse struct {
|
||||
Results []SemanticSearchResult `json:"results"`
|
||||
Query string `json:"query"`
|
||||
Took int64 `json:"took"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// SemanticSearchResult represents a semantic search result
|
||||
type SemanticSearchResult struct {
|
||||
ID uint `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
Highlights []string `json:"highlights"`
|
||||
Tags []models.Tag `json:"tags,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateEmbeddingRequest represents request to generate embeddings
|
||||
type GenerateEmbeddingRequest struct {
|
||||
Text string `json:"text" binding:"required"`
|
||||
ContentType string `json:"content_type"`
|
||||
ContentID uint `json:"content_id"`
|
||||
}
|
||||
|
||||
// GenerateEmbeddingResponse represents embedding generation response
|
||||
type GenerateEmbeddingResponse struct {
|
||||
Embedding []float64 `json:"embedding"`
|
||||
Model string `json:"model"`
|
||||
Dimensions int `json:"dimensions"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// SemanticSearch handles POST /api/v1/search/semantic
|
||||
func SemanticSearch(c *gin.Context) {
|
||||
var req SemanticSearchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if req.Limit == 0 {
|
||||
req.Limit = 20
|
||||
}
|
||||
if req.Threshold == 0 {
|
||||
req.Threshold = 0.7 // Default similarity threshold
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
db := config.GetDB()
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
// Generate embedding for the search query
|
||||
queryEmbedding, err := generateEmbedding(req.Query)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to generate query embedding",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Search for similar content
|
||||
results, err := findSimilarContent(db, userID, queryEmbedding, req.ContentType, req.Limit, req.Threshold)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to search similar content",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
took := time.Since(startTime).Milliseconds()
|
||||
|
||||
response := SemanticSearchResponse{
|
||||
Results: results,
|
||||
Query: req.Query,
|
||||
Took: took,
|
||||
Model: "text-embedding-ada-002",
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GenerateEmbedding handles POST /api/v1/search/embeddings/generate
|
||||
func GenerateEmbedding(c *gin.Context) {
|
||||
var req GenerateEmbeddingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate embedding
|
||||
embedding, err := generateEmbedding(req.Text)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to generate embedding",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Store embedding if content reference is provided
|
||||
if req.ContentType != "" && req.ContentID > 0 {
|
||||
db := config.GetDB()
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
embeddingJSON, _ := json.Marshal(embedding)
|
||||
|
||||
contentEmbedding := models.ContentEmbedding{
|
||||
ContentType: req.ContentType,
|
||||
ContentID: req.ContentID,
|
||||
Embedding: string(embeddingJSON),
|
||||
Model: "text-embedding-ada-002",
|
||||
Dimensions: len(embedding),
|
||||
TextContent: req.Text,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if err := db.Create(&contentEmbedding).Error; err != nil {
|
||||
// Log error but don't fail the request
|
||||
fmt.Printf("Failed to store embedding: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
response := GenerateEmbeddingResponse{
|
||||
Embedding: embedding,
|
||||
Model: "text-embedding-ada-002",
|
||||
Dimensions: len(embedding),
|
||||
Success: true,
|
||||
Message: "Embedding generated successfully",
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// ReindexContent handles POST /api/v1/search/reindex
|
||||
func ReindexContent(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
// Start background job to reindex all content
|
||||
go func() {
|
||||
reindexUserContent(db, userID)
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Content reindexing started in background",
|
||||
"status": "processing",
|
||||
})
|
||||
}
|
||||
|
||||
// generateEmbedding generates embedding for text using OpenAI API (mock implementation)
|
||||
func generateEmbedding(text string) ([]float64, error) {
|
||||
// TODO: Replace with actual OpenAI API call
|
||||
// For now, return a mock embedding for demonstration
|
||||
embedding := make([]float64, 1536) // OpenAI embedding dimensions
|
||||
|
||||
// Generate pseudo-random but deterministic embedding based on text
|
||||
hash := simpleHash(text)
|
||||
for i := range embedding {
|
||||
embedding[i] = math.Sin(float64(hash+i)) * 0.5
|
||||
}
|
||||
|
||||
return embedding, nil
|
||||
}
|
||||
|
||||
// simpleHash creates a simple hash from string
|
||||
func simpleHash(s string) int {
|
||||
hash := 0
|
||||
for _, char := range s {
|
||||
hash = hash*31 + int(char)
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
// findSimilarContent finds content similar to the given embedding
|
||||
func findSimilarContent(db *gorm.DB, userID uint, queryEmbedding []float64, contentType string, limit int, threshold float64) ([]SemanticSearchResult, error) {
|
||||
var results []SemanticSearchResult
|
||||
|
||||
// Get all embeddings for the user
|
||||
var embeddings []models.ContentEmbedding
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
if contentType != "all" && contentType != "" {
|
||||
query = query.Where("content_type = ?", contentType)
|
||||
}
|
||||
|
||||
if err := query.Find(&embeddings).Error; err != nil {
|
||||
return results, err
|
||||
}
|
||||
|
||||
// Calculate similarity scores
|
||||
type similarityScore struct {
|
||||
embedding models.ContentEmbedding
|
||||
score float64
|
||||
}
|
||||
|
||||
var scores []similarityScore
|
||||
|
||||
for _, embedding := range embeddings {
|
||||
var storedEmbedding []float64
|
||||
if err := json.Unmarshal([]byte(embedding.Embedding), &storedEmbedding); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
similarity := cosineSimilarity(queryEmbedding, storedEmbedding)
|
||||
if similarity >= threshold {
|
||||
scores = append(scores, similarityScore{
|
||||
embedding: embedding,
|
||||
score: similarity,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by similarity (descending)
|
||||
for i := 0; i < len(scores)-1; i++ {
|
||||
for j := i + 1; j < len(scores); j++ {
|
||||
if scores[i].score < scores[j].score {
|
||||
scores[i], scores[j] = scores[j], scores[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Limit results
|
||||
if len(scores) > limit {
|
||||
scores = scores[:limit]
|
||||
}
|
||||
|
||||
// Fetch actual content and build results
|
||||
for _, score := range scores {
|
||||
result, err := buildSemanticSearchResult(db, score.embedding, score.score)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// cosineSimilarity calculates cosine similarity between two vectors
|
||||
func cosineSimilarity(a, b []float64) float64 {
|
||||
if len(a) != len(b) {
|
||||
return 0
|
||||
}
|
||||
|
||||
var dotProduct, normA, normB float64
|
||||
|
||||
for i := range a {
|
||||
dotProduct += a[i] * b[i]
|
||||
normA += a[i] * a[i]
|
||||
normB += b[i] * b[i]
|
||||
}
|
||||
|
||||
if normA == 0 || normB == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))
|
||||
}
|
||||
|
||||
// buildSemanticSearchResult builds a search result from embedding and content
|
||||
func buildSemanticSearchResult(db *gorm.DB, embedding models.ContentEmbedding, similarity float64) (SemanticSearchResult, error) {
|
||||
result := SemanticSearchResult{
|
||||
Similarity: similarity,
|
||||
}
|
||||
|
||||
switch embedding.ContentType {
|
||||
case "bookmark":
|
||||
var bookmark models.Bookmark
|
||||
if err := db.Preload("Tags").First(&bookmark, embedding.ContentID).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.ID = bookmark.ID
|
||||
result.Type = "bookmark"
|
||||
result.Title = bookmark.Title
|
||||
result.Description = bookmark.Description
|
||||
result.Content = bookmark.Content
|
||||
result.Tags = bookmark.Tags
|
||||
result.CreatedAt = bookmark.CreatedAt
|
||||
result.UpdatedAt = bookmark.UpdatedAt
|
||||
result.URL = bookmark.URL
|
||||
|
||||
case "task":
|
||||
var task models.Task
|
||||
if err := db.Preload("Tags").First(&task, embedding.ContentID).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.ID = task.ID
|
||||
result.Type = "task"
|
||||
result.Title = task.Title
|
||||
result.Description = task.Description
|
||||
result.Tags = task.Tags
|
||||
result.CreatedAt = task.CreatedAt
|
||||
result.UpdatedAt = task.UpdatedAt
|
||||
result.Status = string(task.Status)
|
||||
result.Priority = string(task.Priority)
|
||||
|
||||
case "note":
|
||||
var note models.Note
|
||||
if err := db.Preload("Tags").First(¬e, embedding.ContentID).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.ID = note.ID
|
||||
result.Type = "note"
|
||||
result.Title = note.Title
|
||||
result.Description = note.Description
|
||||
result.Content = note.Content
|
||||
result.Tags = note.Tags
|
||||
result.CreatedAt = note.CreatedAt
|
||||
result.UpdatedAt = note.UpdatedAt
|
||||
|
||||
case "file":
|
||||
var file models.File
|
||||
if err := db.Preload("Tags").First(&file, embedding.ContentID).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.ID = file.ID
|
||||
result.Type = "file"
|
||||
result.Title = file.OriginalName
|
||||
result.Description = file.Description
|
||||
result.Content = file.Content
|
||||
result.Tags = file.Tags
|
||||
result.CreatedAt = file.CreatedAt
|
||||
result.UpdatedAt = file.UpdatedAt
|
||||
}
|
||||
|
||||
// Generate highlights (simplified)
|
||||
result.Highlights = generateHighlights(embedding.TextContent, 3)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// generateHighlights generates text highlights
|
||||
func generateHighlights(text string, count int) []string {
|
||||
if text == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// Simple highlight generation - split into sentences and return first few
|
||||
sentences := strings.Split(text, ".")
|
||||
if len(sentences) > count {
|
||||
sentences = sentences[:count]
|
||||
}
|
||||
|
||||
var highlights []string
|
||||
for _, sentence := range sentences {
|
||||
sentence = strings.TrimSpace(sentence)
|
||||
if len(sentence) > 10 {
|
||||
highlights = append(highlights, sentence+".")
|
||||
}
|
||||
if len(highlights) >= count {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return highlights
|
||||
}
|
||||
|
||||
// reindexUserContent reindexes all content for a user
|
||||
func reindexUserContent(db *gorm.DB, userID uint) {
|
||||
fmt.Printf("Starting reindexing for user %d\n", userID)
|
||||
|
||||
// Reindex bookmarks
|
||||
var bookmarks []models.Bookmark
|
||||
db.Where("user_id = ?", userID).Find(&bookmarks)
|
||||
|
||||
for _, bookmark := range bookmarks {
|
||||
text := bookmark.Title + " " + bookmark.Description + " " + bookmark.Content
|
||||
embedding, err := generateEmbedding(text)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
embeddingJSON, _ := json.Marshal(embedding)
|
||||
|
||||
contentEmbedding := models.ContentEmbedding{
|
||||
ContentType: "bookmark",
|
||||
ContentID: bookmark.ID,
|
||||
Embedding: string(embeddingJSON),
|
||||
Model: "text-embedding-ada-002",
|
||||
Dimensions: len(embedding),
|
||||
TextContent: text,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
// Delete existing embedding for this content
|
||||
db.Where("content_type = ? AND content_id = ?", "bookmark", bookmark.ID).Delete(&models.ContentEmbedding{})
|
||||
|
||||
// Create new embedding
|
||||
db.Create(&contentEmbedding)
|
||||
}
|
||||
|
||||
// Similar reindexing for tasks, notes, files...
|
||||
// TODO: Implement reindexing for other content types
|
||||
|
||||
fmt.Printf("Reindexing completed for user %d\n", userID)
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SocialHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSocialHandler(db *gorm.DB) *SocialHandler {
|
||||
return &SocialHandler{db: db}
|
||||
}
|
||||
|
||||
// GetProfile retrieves a user's public profile
|
||||
func (h *SocialHandler) GetProfile(c *gin.Context) {
|
||||
userID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := h.db.Preload("Skills").Preload("Projects.Tags").Preload("SocialLinks").
|
||||
First(&user, userID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch profile"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check privacy settings
|
||||
if user.ProfileVisibility == "private" {
|
||||
// Only allow profile owner to see private profile
|
||||
currentUserID, exists := c.Get("user_id")
|
||||
if !exists || uint(currentUserID.(uint)) != user.ID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Profile is private"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare response based on visibility
|
||||
profileResponse := gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"full_name": user.FullName,
|
||||
"avatar_url": user.AvatarURL,
|
||||
"bio": user.Bio,
|
||||
"location": user.Location,
|
||||
"website": user.Website,
|
||||
"company": user.Company,
|
||||
"job_title": user.JobTitle,
|
||||
"skills": user.Skills,
|
||||
"projects": user.Projects,
|
||||
"social_links": user.SocialLinks,
|
||||
"followers_count": user.FollowersCount,
|
||||
"following_count": user.FollowingCount,
|
||||
"public_bookmarks": user.PublicBookmarks,
|
||||
"public_notes": user.PublicNotes,
|
||||
"created_at": user.CreatedAt,
|
||||
}
|
||||
|
||||
// Only show email if user allows it
|
||||
if user.ShowEmail {
|
||||
profileResponse["email"] = user.Email
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, profileResponse)
|
||||
}
|
||||
|
||||
// UpdateProfile updates the current user's profile
|
||||
func (h *SocialHandler) UpdateProfile(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Bio string `json:"bio"`
|
||||
Location string `json:"location"`
|
||||
Website string `json:"website"`
|
||||
Company string `json:"company"`
|
||||
JobTitle string `json:"job_title"`
|
||||
ProfileVisibility string `json:"profile_visibility"`
|
||||
ShowEmail bool `json:"show_email"`
|
||||
ShowActivity bool `json:"show_activity"`
|
||||
AllowMessages bool `json:"allow_messages"`
|
||||
Skills []models.Skill `json:"skills"`
|
||||
SocialLinks []models.SocialLink `json:"social_links"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update user profile
|
||||
user := models.User{}
|
||||
if err := h.db.First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
|
||||
user.Bio = req.Bio
|
||||
user.Location = req.Location
|
||||
user.Website = req.Website
|
||||
user.Company = req.Company
|
||||
user.JobTitle = req.JobTitle
|
||||
user.ProfileVisibility = req.ProfileVisibility
|
||||
user.ShowEmail = req.ShowEmail
|
||||
user.ShowActivity = req.ShowActivity
|
||||
user.AllowMessages = req.AllowMessages
|
||||
|
||||
// Start transaction
|
||||
tx := h.db.Begin()
|
||||
|
||||
// Update user
|
||||
if err := tx.Save(&user).Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update skills - delete existing and create new
|
||||
if err := tx.Where("user_id = ?", user.ID).Delete(&models.Skill{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update skills"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, skill := range req.Skills {
|
||||
skill.UserID = user.ID
|
||||
if err := tx.Create(&skill).Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create skills"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Update social links - delete existing and create new
|
||||
if err := tx.Where("user_id = ?", user.ID).Delete(&models.SocialLink{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update social links"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, link := range req.SocialLinks {
|
||||
link.UserID = user.ID
|
||||
if err := tx.Create(&link).Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create social links"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Profile updated successfully"})
|
||||
}
|
||||
|
||||
// FollowUser follows or unfollows a user
|
||||
func (h *SocialHandler) FollowUser(c *gin.Context) {
|
||||
currentUserID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
targetUserID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Can't follow yourself
|
||||
if uint(currentUserID.(uint)) == uint(targetUserID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot follow yourself"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already following
|
||||
var existingFollow models.Follow
|
||||
err = h.db.Where("follower_id = ? AND following_id = ?", currentUserID, targetUserID).First(&existingFollow).Error
|
||||
|
||||
if err == nil {
|
||||
// Already following, unfollow
|
||||
h.db.Delete(&existingFollow)
|
||||
|
||||
// Update counts
|
||||
h.db.Model(&models.User{}).Where("id = ?", currentUserID).Update("following_count", gorm.Expr("following_count - 1"))
|
||||
h.db.Model(&models.User{}).Where("id = ?", targetUserID).Update("followers_count", gorm.Expr("followers_count - 1"))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Unfollowed successfully", "following": false})
|
||||
} else if err == gorm.ErrRecordNotFound {
|
||||
// Not following, follow
|
||||
newFollow := models.Follow{
|
||||
FollowerID: uint(currentUserID.(uint)),
|
||||
FollowingID: uint(targetUserID),
|
||||
}
|
||||
|
||||
if err := h.db.Create(&newFollow).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to follow user"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update counts
|
||||
h.db.Model(&models.User{}).Where("id = ?", currentUserID).Update("following_count", gorm.Expr("following_count + 1"))
|
||||
h.db.Model(&models.User{}).Where("id = ?", targetUserID).Update("followers_count", gorm.Expr("followers_count + 1"))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Followed successfully", "following": true})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check follow status"})
|
||||
}
|
||||
}
|
||||
|
||||
// GetFollowers retrieves a user's followers
|
||||
func (h *SocialHandler) GetFollowers(c *gin.Context) {
|
||||
userID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset := (page - 1) * limit
|
||||
|
||||
var follows []models.Follow
|
||||
if err := h.db.Preload("Follower").Where("following_id = ?", userID).
|
||||
Offset(offset).Limit(limit).Find(&follows).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch followers"})
|
||||
return
|
||||
}
|
||||
|
||||
var followers []gin.H
|
||||
for _, follow := range follows {
|
||||
followers = append(followers, gin.H{
|
||||
"id": follow.Follower.ID,
|
||||
"username": follow.Follower.Username,
|
||||
"full_name": follow.Follower.FullName,
|
||||
"avatar_url": follow.Follower.AvatarURL,
|
||||
"followed_at": follow.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"followers": followers,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// GetFollowing retrieves who a user is following
|
||||
func (h *SocialHandler) GetFollowing(c *gin.Context) {
|
||||
userID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset := (page - 1) * limit
|
||||
|
||||
var follows []models.Follow
|
||||
if err := h.db.Preload("Following").Where("follower_id = ?", userID).
|
||||
Offset(offset).Limit(limit).Find(&follows).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch following"})
|
||||
return
|
||||
}
|
||||
|
||||
var following []gin.H
|
||||
for _, follow := range follows {
|
||||
following = append(following, gin.H{
|
||||
"id": follow.Following.ID,
|
||||
"username": follow.Following.Username,
|
||||
"full_name": follow.Following.FullName,
|
||||
"avatar_url": follow.Following.AvatarURL,
|
||||
"followed_at": follow.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"following": following,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// SearchUsers searches for users by username, name, or skills
|
||||
func (h *SocialHandler) SearchUsers(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Search query is required"})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset := (page - 1) * limit
|
||||
|
||||
searchTerm := "%" + strings.ToLower(query) + "%"
|
||||
|
||||
var users []models.User
|
||||
if err := h.db.Where("LOWER(username) LIKE ? OR LOWER(full_name) LIKE ? OR LOWER(bio) LIKE ?",
|
||||
searchTerm, searchTerm, searchTerm).
|
||||
Where("profile_visibility = ?", "public").
|
||||
Offset(offset).Limit(limit).Find(&users).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search users"})
|
||||
return
|
||||
}
|
||||
|
||||
var results []gin.H
|
||||
for _, user := range users {
|
||||
results = append(results, gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"full_name": user.FullName,
|
||||
"avatar_url": user.AvatarURL,
|
||||
"bio": user.Bio,
|
||||
"followers_count": user.FollowersCount,
|
||||
"following_count": user.FollowingCount,
|
||||
"public_bookmarks": user.PublicBookmarks,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": results,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/config"
|
||||
@@ -11,6 +13,79 @@ import (
|
||||
|
||||
// GetTasks handles GET /api/v1/tasks
|
||||
func GetTasks(c *gin.Context) {
|
||||
// Check if demo mode is enabled
|
||||
if os.Getenv("VITE_DEMO_MODE") == "true" {
|
||||
// Parse dates for demo mode
|
||||
dueDate1, _ := time.Parse("2006-01-02", "2024-02-15")
|
||||
dueDate2, _ := time.Parse("2006-01-02", "2024-02-10")
|
||||
dueDate3, _ := time.Parse("2006-01-02", "2024-02-01")
|
||||
dueDate4, _ := time.Parse("2006-01-02", "2024-02-08")
|
||||
dueDate5, _ := time.Parse("2006-01-02", "2024-02-20")
|
||||
completedAt := time.Now().AddDate(0, 0, -1)
|
||||
|
||||
// Return mock tasks for demo mode
|
||||
mockTasks := []models.Task{
|
||||
{
|
||||
ID: 1,
|
||||
Title: "Complete API documentation",
|
||||
Description: "Write comprehensive documentation for all API endpoints",
|
||||
Status: "in_progress",
|
||||
Priority: "high",
|
||||
DueDate: &dueDate1,
|
||||
UserID: 1,
|
||||
CreatedAt: time.Now().AddDate(0, 0, -7),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Title: "Fix responsive design issues",
|
||||
Description: "Resolve mobile layout problems on dashboard",
|
||||
Status: "pending",
|
||||
Priority: "medium",
|
||||
DueDate: &dueDate2,
|
||||
UserID: 1,
|
||||
CreatedAt: time.Now().AddDate(0, 0, -3),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
{
|
||||
ID: 3,
|
||||
Title: "Deploy to production",
|
||||
Description: "Deploy latest changes to production environment",
|
||||
Status: "completed",
|
||||
Priority: "high",
|
||||
DueDate: &dueDate3,
|
||||
UserID: 1,
|
||||
CreatedAt: time.Now().AddDate(0, 0, -14),
|
||||
UpdatedAt: time.Now().AddDate(0, 0, -1),
|
||||
CompletedAt: &completedAt,
|
||||
},
|
||||
{
|
||||
ID: 4,
|
||||
Title: "Review pull requests",
|
||||
Description: "Review and merge pending pull requests",
|
||||
Status: "pending",
|
||||
Priority: "medium",
|
||||
DueDate: &dueDate4,
|
||||
UserID: 1,
|
||||
CreatedAt: time.Now().AddDate(0, 0, -1),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
{
|
||||
ID: 5,
|
||||
Title: "Update dependencies",
|
||||
Description: "Update all npm packages to latest stable versions",
|
||||
Status: "pending",
|
||||
Priority: "low",
|
||||
DueDate: &dueDate5,
|
||||
UserID: 1,
|
||||
CreatedAt: time.Now().AddDate(0, 0, -5),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
c.JSON(http.StatusOK, mockTasks)
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
var tasks []models.Task
|
||||
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TeamsHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewTeamsHandler(db *gorm.DB) *TeamsHandler {
|
||||
return &TeamsHandler{db: db}
|
||||
}
|
||||
|
||||
// generateInvitationToken generates a unique token for team invitations
|
||||
func generateInvitationToken() string {
|
||||
bytes := make([]byte, 32)
|
||||
rand.Read(bytes)
|
||||
return hex.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
// GetTeams retrieves teams for the current user
|
||||
func (h *TeamsHandler) GetTeams(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset := (page - 1) * limit
|
||||
|
||||
var teams []models.Team
|
||||
if err := h.db.Preload("Owner").Preload("Members.User").
|
||||
Joins("JOIN team_members ON team_members.team_id = teams.id").
|
||||
Where("team_members.user_id = ?", userID).
|
||||
Offset(offset).Limit(limit).Find(&teams).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch teams"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"teams": teams,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateTeam creates a new team
|
||||
func (h *TeamsHandler) CreateTeam(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Avatar string `json:"avatar"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Start transaction
|
||||
tx := h.db.Begin()
|
||||
|
||||
// Create team
|
||||
team := models.Team{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Avatar: req.Avatar,
|
||||
IsPublic: req.IsPublic,
|
||||
IsActive: true,
|
||||
OwnerID: uint(userID.(uint)),
|
||||
}
|
||||
|
||||
if err := tx.Create(&team).Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create team"})
|
||||
return
|
||||
}
|
||||
|
||||
// Add owner as team member
|
||||
member := models.TeamMember{
|
||||
TeamID: team.ID,
|
||||
UserID: uint(userID.(uint)),
|
||||
Role: "owner",
|
||||
JoinedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := tx.Create(&member).Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add owner to team"})
|
||||
return
|
||||
}
|
||||
|
||||
// Log activity
|
||||
activity := models.TeamActivity{
|
||||
TeamID: team.ID,
|
||||
UserID: uint(userID.(uint)),
|
||||
Action: "created",
|
||||
EntityType: "team",
|
||||
EntityID: team.ID,
|
||||
Details: `{"action": "team_created"}`,
|
||||
}
|
||||
|
||||
tx.Create(&activity)
|
||||
|
||||
tx.Commit()
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"message": "Team created successfully", "team": team})
|
||||
}
|
||||
|
||||
// GetTeam retrieves a specific team
|
||||
func (h *TeamsHandler) GetTeam(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid team ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is a member of the team
|
||||
var member models.TeamMember
|
||||
if err := h.db.Where("team_id = ? AND user_id = ?", teamID, userID).First(&member).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check membership"})
|
||||
return
|
||||
}
|
||||
|
||||
var team models.Team
|
||||
if err := h.db.Preload("Owner").Preload("Members.User").Preload("Projects.Tags").
|
||||
First(&team, teamID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Team not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch team"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"team": team})
|
||||
}
|
||||
|
||||
// UpdateTeam updates a team (only owner or admin)
|
||||
func (h *TeamsHandler) UpdateTeam(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid team ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is owner or admin
|
||||
var member models.TeamMember
|
||||
if err := h.db.Where("team_id = ? AND user_id = ? AND role IN ?", teamID, userID, []string{"owner", "admin"}).
|
||||
First(&member).Error; err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Avatar string `json:"avatar"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
team := models.Team{}
|
||||
if err := h.db.First(&team, teamID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Team not found"})
|
||||
return
|
||||
}
|
||||
|
||||
team.Name = req.Name
|
||||
team.Description = req.Description
|
||||
team.Avatar = req.Avatar
|
||||
team.IsPublic = req.IsPublic
|
||||
team.IsActive = req.IsActive
|
||||
|
||||
if err := h.db.Save(&team).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update team"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Team updated successfully", "team": team})
|
||||
}
|
||||
|
||||
// DeleteTeam deletes a team (only owner)
|
||||
func (h *TeamsHandler) DeleteTeam(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid team ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is owner
|
||||
var member models.TeamMember
|
||||
if err := h.db.Where("team_id = ? AND user_id = ? AND role = ?", teamID, userID, "owner").
|
||||
First(&member).Error; err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Only team owner can delete team"})
|
||||
return
|
||||
}
|
||||
|
||||
// Soft delete team
|
||||
if err := h.db.Delete(&models.Team{}, teamID).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete team"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Team deleted successfully"})
|
||||
}
|
||||
|
||||
// InviteMember invites a user to join a team
|
||||
func (h *TeamsHandler) InviteMember(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid team ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is owner or admin
|
||||
var member models.TeamMember
|
||||
if err := h.db.Where("team_id = ? AND user_id = ? AND role IN ?", teamID, userID, []string{"owner", "admin"}).
|
||||
First(&member).Error; err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Role string `json:"role" binding:"required,oneof=member admin viewer"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is already a member
|
||||
var existingMember models.TeamMember
|
||||
if err := h.db.Joins("JOIN users ON users.id = team_members.user_id").
|
||||
Where("team_members.team_id = ? AND users.email = ?", teamID, req.Email).First(&existingMember).Error; err == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "User is already a team member"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if there's already a pending invitation
|
||||
var existingInvitation models.TeamInvitation
|
||||
if err := h.db.Where("team_id = ? AND email = ? AND status = ?", teamID, req.Email, "pending").
|
||||
First(&existingInvitation).Error; err == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invitation already sent"})
|
||||
return
|
||||
}
|
||||
|
||||
// Find user by email (if registered)
|
||||
var targetUser models.User
|
||||
h.db.Where("email = ?", req.Email).First(&targetUser)
|
||||
|
||||
// Create invitation
|
||||
invitation := models.TeamInvitation{
|
||||
TeamID: uint(teamID),
|
||||
UserID: targetUser.ID,
|
||||
Email: req.Email,
|
||||
Role: req.Role,
|
||||
Token: generateInvitationToken(),
|
||||
Status: "pending",
|
||||
ExpiresAt: time.Now().Add(7 * 24 * time.Hour), // 7 days
|
||||
InvitedBy: uint(userID.(uint)),
|
||||
}
|
||||
|
||||
if err := h.db.Create(&invitation).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create invitation"})
|
||||
return
|
||||
}
|
||||
|
||||
// Log activity
|
||||
activity := models.TeamActivity{
|
||||
TeamID: uint(teamID),
|
||||
UserID: uint(userID.(uint)),
|
||||
Action: "invited",
|
||||
EntityType: "invitation",
|
||||
EntityID: invitation.ID,
|
||||
Details: `{"email": "` + req.Email + `", "role": "` + req.Role + `"}`,
|
||||
}
|
||||
|
||||
h.db.Create(&activity)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"message": "Invitation sent successfully", "invitation": invitation})
|
||||
}
|
||||
|
||||
// AcceptInvitation accepts a team invitation
|
||||
func (h *TeamsHandler) AcceptInvitation(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
token := c.Param("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invitation token is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var invitation models.TeamInvitation
|
||||
if err := h.db.Preload("Team").Where("token = ? AND status = ?", token, "pending").
|
||||
First(&invitation).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Invalid or expired invitation"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch invitation"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if invitation has expired
|
||||
if time.Now().After(invitation.ExpiresAt) {
|
||||
h.db.Model(&invitation).Update("status", "expired")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invitation has expired"})
|
||||
return
|
||||
}
|
||||
|
||||
// Start transaction
|
||||
tx := h.db.Begin()
|
||||
|
||||
// Update invitation status
|
||||
if err := tx.Model(&invitation).Update("status", "accepted").Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update invitation"})
|
||||
return
|
||||
}
|
||||
|
||||
// Add user to team
|
||||
member := models.TeamMember{
|
||||
TeamID: invitation.TeamID,
|
||||
UserID: uint(userID.(uint)),
|
||||
Role: invitation.Role,
|
||||
JoinedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := tx.Create(&member).Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add user to team"})
|
||||
return
|
||||
}
|
||||
|
||||
// Log activity
|
||||
activity := models.TeamActivity{
|
||||
TeamID: invitation.TeamID,
|
||||
UserID: uint(userID.(uint)),
|
||||
Action: "joined",
|
||||
EntityType: "team",
|
||||
EntityID: invitation.TeamID,
|
||||
Details: `{"action": "joined_team"}`,
|
||||
}
|
||||
|
||||
tx.Create(&activity)
|
||||
|
||||
tx.Commit()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Successfully joined team", "team": invitation.Team})
|
||||
}
|
||||
|
||||
// GetTeamMembers retrieves members of a team
|
||||
func (h *TeamsHandler) GetTeamMembers(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid team ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is a member of the team
|
||||
var member models.TeamMember
|
||||
if err := h.db.Where("team_id = ? AND user_id = ?", teamID, userID).First(&member).Error; err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
var members []models.TeamMember
|
||||
if err := h.db.Preload("User").Where("team_id = ?", teamID).Find(&members).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch team members"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"members": members})
|
||||
}
|
||||
|
||||
// RemoveMember removes a member from a team
|
||||
func (h *TeamsHandler) RemoveMember(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid team ID"})
|
||||
return
|
||||
}
|
||||
|
||||
memberID, err := strconv.ParseUint(c.Param("memberId"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid member ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if current user is owner or admin
|
||||
var currentMember models.TeamMember
|
||||
if err := h.db.Where("team_id = ? AND user_id = ? AND role IN ?", teamID, userID, []string{"owner", "admin"}).
|
||||
First(¤tMember).Error; err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
|
||||
return
|
||||
}
|
||||
|
||||
// Cannot remove the owner
|
||||
var targetMember models.TeamMember
|
||||
if err := h.db.Where("team_id = ? AND user_id = ? AND role = ?", teamID, memberID, "owner").
|
||||
First(&targetMember).Error; err == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot remove team owner"})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove member
|
||||
if err := h.db.Where("team_id = ? AND user_id = ?", teamID, memberID).Delete(&models.TeamMember{}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove member"})
|
||||
return
|
||||
}
|
||||
|
||||
// Log activity
|
||||
activity := models.TeamActivity{
|
||||
TeamID: uint(teamID),
|
||||
UserID: uint(userID.(uint)),
|
||||
Action: "removed",
|
||||
EntityType: "member",
|
||||
EntityID: uint(memberID),
|
||||
Details: `{"action": "member_removed"}`,
|
||||
}
|
||||
|
||||
h.db.Create(&activity)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Member removed successfully"})
|
||||
}
|
||||
|
||||
// GetTeamActivity retrieves activity logs for a team
|
||||
func (h *TeamsHandler) GetTeamActivity(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid team ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is a member of the team
|
||||
var member models.TeamMember
|
||||
if err := h.db.Where("team_id = ? AND user_id = ?", teamID, userID).First(&member).Error; err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
offset := (page - 1) * limit
|
||||
|
||||
var activities []models.TeamActivity
|
||||
if err := h.db.Preload("User").Where("team_id = ?", teamID).
|
||||
Order("created_at DESC").Offset(offset).Limit(limit).Find(&activities).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch team activity"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"activities": activities,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// GetTeamStats retrieves statistics for a team
|
||||
func (h *TeamsHandler) GetTeamStats(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid team ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is a member of the team
|
||||
var member models.TeamMember
|
||||
if err := h.db.Where("team_id = ? AND user_id = ?", teamID, userID).First(&member).Error; err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
stats := models.TeamStats{TeamID: uint(teamID)}
|
||||
|
||||
// Count members
|
||||
h.db.Model(&models.TeamMember{}).Where("team_id = ?", teamID).Count(&stats.MembersCount)
|
||||
|
||||
// Count projects
|
||||
h.db.Model(&models.TeamProject{}).Where("team_id = ?", teamID).Count(&stats.ProjectsCount)
|
||||
|
||||
// Count bookmarks
|
||||
h.db.Model(&models.TeamBookmark{}).Where("team_id = ?", teamID).Count(&stats.BookmarksCount)
|
||||
|
||||
// Count notes
|
||||
h.db.Model(&models.TeamNote{}).Where("team_id = ?", teamID).Count(&stats.NotesCount)
|
||||
|
||||
// Count tasks
|
||||
h.db.Model(&models.TeamTask{}).Where("team_id = ?", teamID).Count(&stats.TasksCount)
|
||||
|
||||
// Count files
|
||||
h.db.Model(&models.TeamFile{}).Where("team_id = ?", teamID).Count(&stats.FilesCount)
|
||||
|
||||
// Count recent activity (last 7 days)
|
||||
sevenDaysAgo := time.Now().Add(-7 * 24 * time.Hour)
|
||||
h.db.Model(&models.TeamActivity{}).Where("team_id = ? AND created_at >= ?", teamID, sevenDaysAgo).
|
||||
Count(&stats.RecentActivity)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"stats": stats})
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TimeEntryHandler handles time tracking operations
|
||||
type TimeEntryHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewTimeEntryHandler creates a new time entry handler
|
||||
func NewTimeEntryHandler(db *gorm.DB) *TimeEntryHandler {
|
||||
return &TimeEntryHandler{db: db}
|
||||
}
|
||||
|
||||
// CreateTimeEntryRequest represents the request to create a time entry
|
||||
type CreateTimeEntryRequest struct {
|
||||
TaskID *uint `json:"task_id,omitempty"`
|
||||
BookmarkID *uint `json:"bookmark_id,omitempty"`
|
||||
NoteID *uint `json:"note_id,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Billable bool `json:"billable"`
|
||||
HourlyRate *float64 `json:"hourly_rate,omitempty"`
|
||||
Source string `json:"source" gorm:"default:manual"`
|
||||
}
|
||||
|
||||
// UpdateTimeEntryRequest represents the request to update a time entry
|
||||
type UpdateTimeEntryRequest struct {
|
||||
Description *string `json:"description,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Billable *bool `json:"billable,omitempty"`
|
||||
HourlyRate *float64 `json:"hourly_rate,omitempty"`
|
||||
EndTime *time.Time `json:"end_time,omitempty"`
|
||||
}
|
||||
|
||||
// GetTimeEntries retrieves all time entries for the authenticated user
|
||||
func (h *TimeEntryHandler) GetTimeEntries(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var timeEntries []models.TimeEntry
|
||||
query := h.db.Where("user_id = ?", userID).
|
||||
Preload("Task").
|
||||
Preload("Bookmark").
|
||||
Preload("Note").
|
||||
Preload("Tags").
|
||||
Order("created_at DESC")
|
||||
|
||||
// Filter by date range if provided
|
||||
if startDate := c.Query("start_date"); startDate != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", startDate); err == nil {
|
||||
query = query.Where("start_time >= ?", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
if endDate := c.Query("end_date"); endDate != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", endDate); err == nil {
|
||||
query = query.Where("start_time <= ?", parsed.Add(24*time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by running status
|
||||
if isRunning := c.Query("is_running"); isRunning != "" {
|
||||
running := isRunning == "true"
|
||||
query = query.Where("is_running = ?", running)
|
||||
}
|
||||
|
||||
if err := query.Find(&timeEntries).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve time entries"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"time_entries": timeEntries})
|
||||
}
|
||||
|
||||
// GetTimeEntry retrieves a specific time entry
|
||||
func (h *TimeEntryHandler) GetTimeEntry(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid time entry ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var timeEntry models.TimeEntry
|
||||
if err := h.db.Where("id = ? AND user_id = ?", id, userID).
|
||||
Preload("Task").
|
||||
Preload("Bookmark").
|
||||
Preload("Note").
|
||||
Preload("Tags").
|
||||
First(&timeEntry).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Time entry not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve time entry"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"time_entry": timeEntry})
|
||||
}
|
||||
|
||||
// CreateTimeEntry creates a new time entry
|
||||
func (h *TimeEntryHandler) CreateTimeEntry(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req CreateTimeEntryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
timeEntry := models.TimeEntry{
|
||||
UserID: userID,
|
||||
TaskID: req.TaskID,
|
||||
BookmarkID: req.BookmarkID,
|
||||
NoteID: req.NoteID,
|
||||
Description: req.Description,
|
||||
Billable: req.Billable,
|
||||
HourlyRate: req.HourlyRate,
|
||||
Source: req.Source,
|
||||
StartTime: time.Now(),
|
||||
IsRunning: true,
|
||||
}
|
||||
|
||||
// Handle tags
|
||||
if len(req.Tags) > 0 {
|
||||
var tags []models.Tag
|
||||
for _, tagName := range req.Tags {
|
||||
var tag models.Tag
|
||||
if err := h.db.Where("name = ?", tagName).FirstOrCreate(&tag, models.Tag{Name: tagName}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create tags"})
|
||||
return
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
timeEntry.Tags = tags
|
||||
}
|
||||
|
||||
if err := h.db.Create(&timeEntry).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create time entry"})
|
||||
return
|
||||
}
|
||||
|
||||
// Load relationships for response
|
||||
h.db.Preload("Task").Preload("Bookmark").Preload("Note").Preload("Tags").First(&timeEntry)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"time_entry": timeEntry})
|
||||
}
|
||||
|
||||
// UpdateTimeEntry updates an existing time entry
|
||||
func (h *TimeEntryHandler) UpdateTimeEntry(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid time entry ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var timeEntry models.TimeEntry
|
||||
if err := h.db.Where("id = ? AND user_id = ?", id, userID).First(&timeEntry).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Time entry not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve time entry"})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateTimeEntryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if req.Description != nil {
|
||||
timeEntry.Description = *req.Description
|
||||
}
|
||||
if req.Billable != nil {
|
||||
timeEntry.Billable = *req.Billable
|
||||
}
|
||||
if req.HourlyRate != nil {
|
||||
timeEntry.HourlyRate = req.HourlyRate
|
||||
}
|
||||
if req.EndTime != nil {
|
||||
timeEntry.EndTime = req.EndTime
|
||||
timeEntry.IsRunning = false
|
||||
}
|
||||
|
||||
// Handle tags
|
||||
if req.Tags != nil {
|
||||
// Clear existing tags
|
||||
h.db.Model(&timeEntry).Association("Tags").Clear()
|
||||
|
||||
// Add new tags
|
||||
var tags []models.Tag
|
||||
for _, tagName := range req.Tags {
|
||||
var tag models.Tag
|
||||
if err := h.db.Where("name = ?", tagName).FirstOrCreate(&tag, models.Tag{Name: tagName}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create tags"})
|
||||
return
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
timeEntry.Tags = tags
|
||||
}
|
||||
|
||||
if err := h.db.Save(&timeEntry).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update time entry"})
|
||||
return
|
||||
}
|
||||
|
||||
// Load relationships for response
|
||||
h.db.Preload("Task").Preload("Bookmark").Preload("Note").Preload("Tags").First(&timeEntry)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"time_entry": timeEntry})
|
||||
}
|
||||
|
||||
// StopTimeEntry stops a running time entry
|
||||
func (h *TimeEntryHandler) StopTimeEntry(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid time entry ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var timeEntry models.TimeEntry
|
||||
if err := h.db.Where("id = ? AND user_id = ?", id, userID).First(&timeEntry).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Time entry not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve time entry"})
|
||||
return
|
||||
}
|
||||
|
||||
if !timeEntry.IsRunning {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Time entry is already stopped"})
|
||||
return
|
||||
}
|
||||
|
||||
timeEntry.Stop()
|
||||
if err := h.db.Save(&timeEntry).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to stop time entry"})
|
||||
return
|
||||
}
|
||||
|
||||
// Load relationships for response
|
||||
h.db.Preload("Task").Preload("Bookmark").Preload("Note").Preload("Tags").First(&timeEntry)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"time_entry": timeEntry})
|
||||
}
|
||||
|
||||
// DeleteTimeEntry deletes a time entry
|
||||
func (h *TimeEntryHandler) DeleteTimeEntry(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid time entry ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var timeEntry models.TimeEntry
|
||||
if err := h.db.Where("id = ? AND user_id = ?", id, userID).First(&timeEntry).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Time entry not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve time entry"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Delete(&timeEntry).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete time entry"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Time entry deleted successfully"})
|
||||
}
|
||||
|
||||
// GetTimeStats retrieves time tracking statistics
|
||||
func (h *TimeEntryHandler) GetTimeStats(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var stats struct {
|
||||
TotalTimeSeconds int64 `json:"total_time_seconds"`
|
||||
TotalEntries int64 `json:"total_entries"`
|
||||
RunningEntries int64 `json:"running_entries"`
|
||||
BillableTime int64 `json:"billable_time_seconds"`
|
||||
TotalBillable float64 `json:"total_billable_amount"`
|
||||
}
|
||||
|
||||
// Total time and entries
|
||||
h.db.Model(&models.TimeEntry{}).
|
||||
Where("user_id = ?", userID).
|
||||
Select("COALESCE(SUM(duration), 0) as total_time_seconds, COUNT(*) as total_entries").
|
||||
Scan(&stats)
|
||||
|
||||
// Running entries
|
||||
h.db.Model(&models.TimeEntry{}).
|
||||
Where("user_id = ? AND is_running = ?", userID, true).
|
||||
Count(&stats.RunningEntries)
|
||||
|
||||
// Billable time and amount
|
||||
var billableStats struct {
|
||||
BillableTime int64 `json:"billable_time"`
|
||||
TotalBillable float64 `json:"total_billable"`
|
||||
}
|
||||
|
||||
h.db.Model(&models.TimeEntry{}).
|
||||
Where("user_id = ? AND billable = ?", userID, true).
|
||||
Select("COALESCE(SUM(duration), 0) as billable_time, COALESCE(SUM(duration * hourly_rate / 3600), 0) as total_billable").
|
||||
Scan(&billableStats)
|
||||
|
||||
stats.BillableTime = billableStats.BillableTime
|
||||
stats.TotalBillable = billableStats.TotalBillable
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"stats": stats})
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// TOTPSetupRequest represents the request to setup TOTP
|
||||
type TOTPSetupRequest struct {
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// TOTPSetupResponse represents the response with TOTP setup details
|
||||
type TOTPSetupResponse struct {
|
||||
Secret string `json:"secret"`
|
||||
QRCode string `json:"qr_code"`
|
||||
BackupCodes []string `json:"backup_codes"`
|
||||
}
|
||||
|
||||
// TOTPVerifyRequest represents the request to verify TOTP
|
||||
type TOTPVerifyRequest struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
// TOTPEnableRequest represents the request to enable TOTP
|
||||
type TOTPEnableRequest struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
// TOTPDisableRequest represents the request to disable TOTP
|
||||
type TOTPDisableRequest struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// TOTPLoginRequest represents the request for login with TOTP
|
||||
type TOTPLoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
TOTPCode string `json:"totp_code"`
|
||||
}
|
||||
|
||||
// encrypt encrypts text using AES-GCM
|
||||
func encrypt(plaintext string) (string, error) {
|
||||
key := []byte(os.Getenv("ENCRYPTION_KEY"))
|
||||
if len(key) != 32 {
|
||||
return "", fmt.Errorf("encryption key must be 32 bytes")
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// decrypt decrypts text using AES-GCM
|
||||
func decrypt(ciphertext string) (string, error) {
|
||||
key := []byte(os.Getenv("ENCRYPTION_KEY"))
|
||||
if len(key) != 32 {
|
||||
return "", fmt.Errorf("encryption key must be 32 bytes")
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return "", fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertext_bytes := data[:nonceSize], data[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext_bytes, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// generateBackupCodes generates backup codes for 2FA
|
||||
func generateBackupCodes() []string {
|
||||
codes := make([]string, 10)
|
||||
for i := range codes {
|
||||
codes[i] = fmt.Sprintf("%08d", i+10000000)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
// SetupTOTP generates a new TOTP secret and QR code for the user
|
||||
func SetupTOTP(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := user.(models.User)
|
||||
var req TOTPSetupRequest
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(currentUser.Password), []byte(req.Password)); err != nil {
|
||||
c.JSON(401, gin.H{"error": "Invalid password"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate TOTP key
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: "Trackeep",
|
||||
AccountName: currentUser.Email,
|
||||
SecretSize: 32,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to generate TOTP secret"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate backup codes
|
||||
backupCodes := generateBackupCodes()
|
||||
|
||||
// Encrypt backup codes for storage
|
||||
backupCodesJSON, _ := json.Marshal(backupCodes)
|
||||
encryptedBackupCodes, err := encrypt(string(backupCodesJSON))
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to encrypt backup codes"})
|
||||
return
|
||||
}
|
||||
|
||||
// Store encrypted TOTP secret and backup codes temporarily (not enabled yet)
|
||||
encryptedSecret, err := encrypt(key.Secret())
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to encrypt TOTP secret"})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
updates := map[string]interface{}{
|
||||
"totp_secret": encryptedSecret,
|
||||
"backup_codes": encryptedBackupCodes,
|
||||
}
|
||||
|
||||
if err := db.Model(¤tUser).Updates(updates).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to store TOTP setup"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate QR code
|
||||
qrCode, err := key.Image(256, 256)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to generate QR code"})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert QR code to base64
|
||||
var qrBuffer bytes.Buffer
|
||||
if err := png.Encode(&qrBuffer, qrCode); err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to encode QR code"})
|
||||
return
|
||||
}
|
||||
qrCodeBase64 := base64.StdEncoding.EncodeToString(qrBuffer.Bytes())
|
||||
|
||||
c.JSON(200, TOTPSetupResponse{
|
||||
Secret: key.Secret(),
|
||||
QRCode: fmt.Sprintf("data:image/png;base64,%s", qrCodeBase64),
|
||||
BackupCodes: backupCodes,
|
||||
})
|
||||
}
|
||||
|
||||
// VerifyTOTP verifies a TOTP code during setup
|
||||
func VerifyTOTP(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := user.(models.User)
|
||||
var req TOTPVerifyRequest
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get encrypted TOTP secret
|
||||
if currentUser.TOTPSecret == "" {
|
||||
c.JSON(400, gin.H{"error": "TOTP not set up"})
|
||||
return
|
||||
}
|
||||
|
||||
secret, err := decrypt(currentUser.TOTPSecret)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to decrypt TOTP secret"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify TOTP code
|
||||
valid := totp.Validate(req.Code, secret)
|
||||
if !valid {
|
||||
c.JSON(400, gin.H{"error": "Invalid TOTP code"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"valid": true})
|
||||
}
|
||||
|
||||
// EnableTOTP enables TOTP authentication for the user
|
||||
func EnableTOTP(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := user.(models.User)
|
||||
var req TOTPEnableRequest
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get encrypted TOTP secret
|
||||
if currentUser.TOTPSecret == "" {
|
||||
c.JSON(400, gin.H{"error": "TOTP not set up"})
|
||||
return
|
||||
}
|
||||
|
||||
secret, err := decrypt(currentUser.TOTPSecret)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to decrypt TOTP secret"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify TOTP code
|
||||
valid := totp.Validate(req.Code, secret)
|
||||
if !valid {
|
||||
c.JSON(400, gin.H{"error": "Invalid TOTP code"})
|
||||
return
|
||||
}
|
||||
|
||||
// Enable TOTP
|
||||
db := config.GetDB()
|
||||
if err := db.Model(¤tUser).Update("totp_enabled", true).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to enable TOTP"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"message": "TOTP enabled successfully"})
|
||||
}
|
||||
|
||||
// DisableTOTP disables TOTP authentication for the user
|
||||
func DisableTOTP(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := user.(models.User)
|
||||
var req TOTPDisableRequest
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(currentUser.Password), []byte(req.Password)); err != nil {
|
||||
c.JSON(401, gin.H{"error": "Invalid password"})
|
||||
return
|
||||
}
|
||||
|
||||
// If TOTP is enabled, verify the code
|
||||
if currentUser.TOTPEnabled {
|
||||
if currentUser.TOTPSecret == "" {
|
||||
c.JSON(400, gin.H{"error": "TOTP not set up"})
|
||||
return
|
||||
}
|
||||
|
||||
secret, err := decrypt(currentUser.TOTPSecret)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to decrypt TOTP secret"})
|
||||
return
|
||||
}
|
||||
|
||||
valid := totp.Validate(req.Code, secret)
|
||||
if !valid {
|
||||
c.JSON(400, gin.H{"error": "Invalid TOTP code"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Disable TOTP and clear secrets
|
||||
db := config.GetDB()
|
||||
updates := map[string]interface{}{
|
||||
"totp_enabled": false,
|
||||
"totp_secret": "",
|
||||
"backup_codes": "",
|
||||
}
|
||||
|
||||
if err := db.Model(¤tUser).Updates(updates).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to disable TOTP"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"message": "TOTP disabled successfully"})
|
||||
}
|
||||
|
||||
// GetTOTPStatus returns the current TOTP status for the user
|
||||
func GetTOTPStatus(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := user.(models.User)
|
||||
|
||||
status := gin.H{
|
||||
"enabled": currentUser.TOTPEnabled,
|
||||
"setup": currentUser.TOTPSecret != "",
|
||||
}
|
||||
|
||||
c.JSON(200, status)
|
||||
}
|
||||
|
||||
// VerifyBackupCode verifies a backup code
|
||||
func VerifyBackupCode(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := user.(models.User)
|
||||
var req TOTPVerifyRequest
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if currentUser.BackupCodes == "" {
|
||||
c.JSON(400, gin.H{"error": "No backup codes available"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt backup codes
|
||||
backupCodesJSON, err := decrypt(currentUser.BackupCodes)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to decrypt backup codes"})
|
||||
return
|
||||
}
|
||||
|
||||
var backupCodes []string
|
||||
if err := json.Unmarshal([]byte(backupCodesJSON), &backupCodes); err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to parse backup codes"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the provided code is valid
|
||||
codeIndex := -1
|
||||
for i, code := range backupCodes {
|
||||
if code == req.Code {
|
||||
codeIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if codeIndex == -1 {
|
||||
c.JSON(400, gin.H{"error": "Invalid backup code"})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the used backup code
|
||||
backupCodes = append(backupCodes[:codeIndex], backupCodes[codeIndex+1:]...)
|
||||
|
||||
// Re-encrypt and save remaining backup codes
|
||||
newBackupCodesJSON, _ := json.Marshal(backupCodes)
|
||||
encryptedBackupCodes, err := encrypt(string(newBackupCodesJSON))
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to encrypt backup codes"})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
if err := db.Model(¤tUser).Update("backup_codes", encryptedBackupCodes).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to update backup codes"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"valid": true, "remaining_codes": len(backupCodes)})
|
||||
}
|
||||
|
||||
// LoginWithTOTP handles login with TOTP verification
|
||||
func LoginWithTOTP(c *gin.Context) {
|
||||
var req TOTPLoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if demo mode is enabled first
|
||||
if os.Getenv("VITE_DEMO_MODE") == "true" && req.Email == "[email protected]" && req.Password == "demo123" {
|
||||
// Create demo user
|
||||
demoUser := models.User{
|
||||
ID: 1,
|
||||
Email: "[email protected]",
|
||||
Username: "demo",
|
||||
FullName: "Demo User",
|
||||
Theme: "dark",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Generate JWT token for demo user
|
||||
token, err := GenerateJWT(demoUser)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, AuthResponse{
|
||||
Token: token,
|
||||
User: demoUser,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
|
||||
// Find user
|
||||
var user models.User
|
||||
if err := db.Where("email = ?", req.Email).First(&user).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(401, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
}
|
||||
c.JSON(500, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if account is locked
|
||||
if user.LockedUntil != nil && user.LockedUntil.After(time.Now()) {
|
||||
c.JSON(423, gin.H{"error": "Account temporarily locked due to too many failed attempts"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
||||
// Increment login attempts
|
||||
user.LoginAttempts++
|
||||
if user.LoginAttempts >= 5 {
|
||||
lockDuration := time.Now().Add(time.Duration(user.LoginAttempts) * time.Minute)
|
||||
user.LockedUntil = &lockDuration
|
||||
}
|
||||
|
||||
db.Model(&user).Updates(map[string]interface{}{
|
||||
"login_attempts": user.LoginAttempts,
|
||||
"locked_until": user.LockedUntil,
|
||||
})
|
||||
|
||||
c.JSON(401, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
// If TOTP is enabled, verify the code
|
||||
if user.TOTPEnabled {
|
||||
if req.TOTPCode == "" {
|
||||
// Return a special response indicating TOTP is required
|
||||
c.JSON(200, gin.H{
|
||||
"requires_totp": true,
|
||||
"message": "TOTP code required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it's a backup code first
|
||||
if len(req.TOTPCode) == 8 && strings.HasPrefix(req.TOTPCode, "1") {
|
||||
// This looks like a backup code
|
||||
if user.BackupCodes == "" {
|
||||
c.JSON(401, gin.H{"error": "Invalid backup code"})
|
||||
return
|
||||
}
|
||||
|
||||
backupCodesJSON, err := decrypt(user.BackupCodes)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to verify backup code"})
|
||||
return
|
||||
}
|
||||
|
||||
var backupCodes []string
|
||||
if err := json.Unmarshal([]byte(backupCodesJSON), &backupCodes); err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to verify backup code"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the provided code is valid
|
||||
codeIndex := -1
|
||||
for i, code := range backupCodes {
|
||||
if code == req.TOTPCode {
|
||||
codeIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if codeIndex == -1 {
|
||||
c.JSON(401, gin.H{"error": "Invalid backup code"})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the used backup code
|
||||
backupCodes = append(backupCodes[:codeIndex], backupCodes[codeIndex+1:]...)
|
||||
newBackupCodesJSON, _ := json.Marshal(backupCodes)
|
||||
encryptedBackupCodes, _ := encrypt(string(newBackupCodesJSON))
|
||||
db.Model(&user).Update("backup_codes", encryptedBackupCodes)
|
||||
} else {
|
||||
// Verify TOTP code
|
||||
if user.TOTPSecret == "" {
|
||||
c.JSON(401, gin.H{"error": "TOTP not properly configured"})
|
||||
return
|
||||
}
|
||||
|
||||
secret, err := decrypt(user.TOTPSecret)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to verify TOTP code"})
|
||||
return
|
||||
}
|
||||
|
||||
valid := totp.Validate(req.TOTPCode, secret)
|
||||
if !valid {
|
||||
c.JSON(401, gin.H{"error": "Invalid TOTP code"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset login attempts on successful login
|
||||
now := time.Now()
|
||||
db.Model(&user).Updates(map[string]interface{}{
|
||||
"login_attempts": 0,
|
||||
"locked_until": nil,
|
||||
"last_login_at": &now,
|
||||
})
|
||||
|
||||
// Generate JWT token
|
||||
token, err := GenerateJWT(user)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove password from response
|
||||
user.Password = ""
|
||||
|
||||
c.JSON(200, AuthResponse{
|
||||
Token: token,
|
||||
User: user,
|
||||
})
|
||||
}
|
||||
|
||||
// RegenerateBackupCodes generates new backup codes
|
||||
func RegenerateBackupCodes(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(401, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := user.(models.User)
|
||||
var req TOTPVerifyRequest
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if !currentUser.TOTPEnabled {
|
||||
c.JSON(400, gin.H{"error": "TOTP is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify current TOTP code
|
||||
if currentUser.TOTPSecret == "" {
|
||||
c.JSON(400, gin.H{"error": "TOTP not set up"})
|
||||
return
|
||||
}
|
||||
|
||||
secret, err := decrypt(currentUser.TOTPSecret)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to decrypt TOTP secret"})
|
||||
return
|
||||
}
|
||||
|
||||
valid := totp.Validate(req.Code, secret)
|
||||
if !valid {
|
||||
c.JSON(400, gin.H{"error": "Invalid TOTP code"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new backup codes
|
||||
backupCodes := generateBackupCodes()
|
||||
backupCodesJSON, _ := json.Marshal(backupCodes)
|
||||
encryptedBackupCodes, err := encrypt(string(backupCodesJSON))
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to encrypt backup codes"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update backup codes
|
||||
db := config.GetDB()
|
||||
if err := db.Model(¤tUser).Update("backup_codes", encryptedBackupCodes).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to update backup codes"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"message": "Backup codes regenerated successfully",
|
||||
"backup_codes": backupCodes,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// UpdateInfo represents information about an available update
|
||||
type UpdateInfo struct {
|
||||
Version string `json:"version"`
|
||||
ReleaseNotes string `json:"releaseNotes"`
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
Mandatory bool `json:"mandatory"`
|
||||
Size string `json:"size"`
|
||||
}
|
||||
|
||||
// UpdateStatus represents the current status of an update
|
||||
type UpdateStatus struct {
|
||||
Available bool `json:"available"`
|
||||
Downloading bool `json:"downloading"`
|
||||
Installing bool `json:"installing"`
|
||||
Completed bool `json:"completed"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Progress float64 `json:"progress"`
|
||||
}
|
||||
|
||||
// UpdateRequest represents an update installation request
|
||||
type UpdateRequest struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// Global update state
|
||||
var (
|
||||
updateMutex sync.RWMutex
|
||||
currentUpdate *UpdateInfo
|
||||
updateProgress *UpdateStatus
|
||||
)
|
||||
|
||||
func init() {
|
||||
updateProgress = &UpdateStatus{
|
||||
Available: false,
|
||||
Downloading: false,
|
||||
Installing: false,
|
||||
Completed: false,
|
||||
Error: "",
|
||||
Progress: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// CheckForUpdates checks if a new version is available
|
||||
func CheckForUpdates(c *gin.Context) {
|
||||
updateMutex.Lock()
|
||||
defer updateMutex.Unlock()
|
||||
|
||||
// Get current version from environment or default
|
||||
currentVersion := os.Getenv("APP_VERSION")
|
||||
if currentVersion == "" {
|
||||
currentVersion = "1.0.0"
|
||||
}
|
||||
|
||||
// In a real implementation, this would check against a remote update server
|
||||
// For demo purposes, we'll simulate checking for updates
|
||||
latestVersion, updateAvailable := simulateUpdateCheck(currentVersion)
|
||||
|
||||
if updateAvailable {
|
||||
currentUpdate = &UpdateInfo{
|
||||
Version: latestVersion,
|
||||
ReleaseNotes: "• New AI features added\n• Performance improvements\n• Bug fixes and security patches\n• Enhanced user interface",
|
||||
DownloadURL: "https://github.com/trackeep/trackeep/releases/latest",
|
||||
Mandatory: false,
|
||||
Size: "~25MB",
|
||||
}
|
||||
updateProgress.Available = true
|
||||
} else {
|
||||
currentUpdate = nil
|
||||
updateProgress.Available = false
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"updateAvailable": updateAvailable,
|
||||
"currentVersion": currentVersion,
|
||||
"latestVersion": latestVersion,
|
||||
"updateInfo": currentUpdate,
|
||||
})
|
||||
}
|
||||
|
||||
// InstallUpdate starts the update installation process
|
||||
func InstallUpdate(c *gin.Context) {
|
||||
var req UpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
updateMutex.Lock()
|
||||
defer updateMutex.Unlock()
|
||||
|
||||
if currentUpdate == nil || currentUpdate.Version != req.Version {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Update not available"})
|
||||
return
|
||||
}
|
||||
|
||||
if updateProgress.Downloading || updateProgress.Installing {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Update already in progress"})
|
||||
return
|
||||
}
|
||||
|
||||
// Start update process in background
|
||||
go performUpdate(currentUpdate)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Update started",
|
||||
"version": req.Version,
|
||||
})
|
||||
}
|
||||
|
||||
// GetUpdateProgress returns the current update progress
|
||||
func GetUpdateProgress(c *gin.Context) {
|
||||
updateMutex.RLock()
|
||||
defer updateMutex.RUnlock()
|
||||
|
||||
c.JSON(http.StatusOK, updateProgress)
|
||||
}
|
||||
|
||||
// WebSocket endpoint for real-time update progress
|
||||
func UpdateProgressWebSocket(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "WebSocket support not implemented, using polling instead",
|
||||
"progress": updateProgress,
|
||||
})
|
||||
}
|
||||
|
||||
// simulateUpdateCheck simulates checking for updates
|
||||
func simulateUpdateCheck(currentVersion string) (string, bool) {
|
||||
// Simulate version check - in reality this would call an update API
|
||||
versions := []string{"1.0.1", "1.1.0", "1.2.0"}
|
||||
|
||||
// For demo, always return a newer version
|
||||
if len(versions) > 0 {
|
||||
return versions[0], true
|
||||
}
|
||||
|
||||
return currentVersion, false
|
||||
}
|
||||
|
||||
// performUpdate performs the actual update process
|
||||
func performUpdate(updateInfo *UpdateInfo) {
|
||||
updateMutex.Lock()
|
||||
updateProgress.Downloading = true
|
||||
updateProgress.Progress = 0
|
||||
updateProgress.Error = ""
|
||||
updateMutex.Unlock()
|
||||
|
||||
// Broadcast progress update
|
||||
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
|
||||
|
||||
// Simulate download
|
||||
for i := 0; i <= 100; i += 10 {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
updateMutex.Lock()
|
||||
updateProgress.Progress = float64(i)
|
||||
updateMutex.Unlock()
|
||||
|
||||
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
|
||||
}
|
||||
|
||||
// Start installation
|
||||
updateMutex.Lock()
|
||||
updateProgress.Downloading = false
|
||||
updateProgress.Installing = true
|
||||
updateProgress.Progress = 0
|
||||
updateMutex.Unlock()
|
||||
|
||||
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
|
||||
|
||||
// Backup user data
|
||||
if err := backupUserData(); err != nil {
|
||||
updateMutex.Lock()
|
||||
updateProgress.Installing = false
|
||||
updateProgress.Error = fmt.Sprintf("Failed to backup user data: %v", err)
|
||||
updateMutex.Unlock()
|
||||
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
|
||||
return
|
||||
}
|
||||
|
||||
// Simulate installation
|
||||
for i := 0; i <= 100; i += 20 {
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
updateMutex.Lock()
|
||||
updateProgress.Progress = float64(i)
|
||||
updateMutex.Unlock()
|
||||
|
||||
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
|
||||
}
|
||||
|
||||
// Perform the actual update
|
||||
if err := applyUpdate(updateInfo); err != nil {
|
||||
updateMutex.Lock()
|
||||
updateProgress.Installing = false
|
||||
updateProgress.Error = fmt.Sprintf("Failed to apply update: %v", err)
|
||||
updateMutex.Unlock()
|
||||
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
|
||||
return
|
||||
}
|
||||
|
||||
// Mark as completed
|
||||
updateMutex.Lock()
|
||||
updateProgress.Installing = false
|
||||
updateProgress.Completed = true
|
||||
updateProgress.Progress = 100
|
||||
updateMutex.Unlock()
|
||||
|
||||
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
|
||||
|
||||
// Trigger application restart after a delay
|
||||
time.Sleep(2 * time.Second)
|
||||
restartApplication()
|
||||
}
|
||||
|
||||
// backupUserData creates a backup of user data
|
||||
func backupUserData() error {
|
||||
backupDir := filepath.Join(os.TempDir(), "trackeep_backup", time.Now().Format("20060102_150405"))
|
||||
|
||||
// Create backup directory
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create backup directory: %w", err)
|
||||
}
|
||||
|
||||
// Backup database
|
||||
dbPath := os.Getenv("DB_PATH")
|
||||
if dbPath == "" {
|
||||
dbPath = "./trackeep.db"
|
||||
}
|
||||
|
||||
if _, err := os.Stat(dbPath); err == nil {
|
||||
backupDBPath := filepath.Join(backupDir, "trackeep.db")
|
||||
if err := copyFile(dbPath, backupDBPath); err != nil {
|
||||
return fmt.Errorf("failed to backup database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Backup uploads directory
|
||||
uploadsDir := "./uploads"
|
||||
if _, err := os.Stat(uploadsDir); err == nil {
|
||||
backupUploadsDir := filepath.Join(backupDir, "uploads")
|
||||
if err := copyDirectory(uploadsDir, backupUploadsDir); err != nil {
|
||||
return fmt.Errorf("failed to backup uploads: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Backup configuration files
|
||||
configFiles := []string{".env", "docker-compose.yml"}
|
||||
for _, file := range configFiles {
|
||||
if _, err := os.Stat(file); err == nil {
|
||||
backupFile := filepath.Join(backupDir, file)
|
||||
if err := copyFile(file, backupFile); err != nil {
|
||||
log.Printf("Warning: failed to backup %s: %v", file, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("User data backed up to: %s", backupDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyUpdate applies the update
|
||||
func applyUpdate(updateInfo *UpdateInfo) error {
|
||||
// In a real implementation, this would:
|
||||
// 1. Download the new version
|
||||
// 2. Verify checksums
|
||||
// 3. Extract/update files
|
||||
// 4. Run database migrations if needed
|
||||
// 5. Restore user data if necessary
|
||||
|
||||
log.Printf("Applying update to version %s", updateInfo.Version)
|
||||
|
||||
// Simulate file update
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Update version in environment
|
||||
os.Setenv("APP_VERSION", updateInfo.Version)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// restartApplication restarts the application
|
||||
func restartApplication() {
|
||||
log.Println("Restarting application to complete update...")
|
||||
|
||||
// Create a new process to replace the current one
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Printf("Failed to get executable path: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Use different commands based on OS
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = exec.Command("powershell", "-Command", "Start-Sleep 2; "+executable)
|
||||
default:
|
||||
cmd = exec.Command("sh", "-c", fmt.Sprintf("sleep 2 && %s", executable))
|
||||
}
|
||||
|
||||
// Start the new process
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Printf("Failed to start new process: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Exit the current process
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// broadcastProgress broadcasts update progress to all WebSocket clients (simplified version)
|
||||
func broadcastProgress() {
|
||||
updateMutex.RLock()
|
||||
progress := *updateProgress
|
||||
updateMutex.RUnlock()
|
||||
|
||||
log.Printf("Update progress: %.1f%% - Status: %v", progress.Progress, getUpdateStatusString(progress))
|
||||
}
|
||||
|
||||
// getUpdateStatusString returns a human-readable status string
|
||||
func getUpdateStatusString(status UpdateStatus) string {
|
||||
if status.Completed {
|
||||
return "Completed"
|
||||
}
|
||||
if status.Error != "" {
|
||||
return "Error: " + status.Error
|
||||
}
|
||||
if status.Installing {
|
||||
return "Installing"
|
||||
}
|
||||
if status.Downloading {
|
||||
return "Downloading"
|
||||
}
|
||||
if status.Available {
|
||||
return "Available"
|
||||
}
|
||||
return "Not Available"
|
||||
}
|
||||
|
||||
// copyFile copies a file from src to dst
|
||||
func copyFile(src, dst string) error {
|
||||
sourceFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
destFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
_, err = io.Copy(destFile, sourceFile)
|
||||
return err
|
||||
}
|
||||
|
||||
// copyDirectory copies a directory recursively
|
||||
func copyDirectory(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstPath := filepath.Join(dst, relPath)
|
||||
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(dstPath, info.Mode())
|
||||
}
|
||||
|
||||
return copyFile(path, dstPath)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/services"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// VideoBookmarkHandler handles video bookmark API endpoints
|
||||
type VideoBookmarkHandler struct {
|
||||
bookmarkService *services.VideoBookmarkService
|
||||
}
|
||||
|
||||
// NewVideoBookmarkHandler creates a new video bookmark handler
|
||||
func NewVideoBookmarkHandler() *VideoBookmarkHandler {
|
||||
var db *gorm.DB
|
||||
if config.GetDB() != nil {
|
||||
db = config.GetDB()
|
||||
}
|
||||
bookmarkService := services.NewVideoBookmarkService(db)
|
||||
return &VideoBookmarkHandler{bookmarkService: bookmarkService}
|
||||
}
|
||||
|
||||
// SaveVideoBookmark saves a video bookmark
|
||||
func (vbh *VideoBookmarkHandler) SaveVideoBookmark(c *gin.Context) {
|
||||
var req services.SaveVideoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid request format",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Get user ID from JWT token (for now using demo user ID 1)
|
||||
userID := uint(1)
|
||||
|
||||
bookmark, err := vbh.bookmarkService.SaveVideoBookmark(userID, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to save bookmark",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"bookmark": bookmark,
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserBookmarks gets all bookmarks for a user
|
||||
func (vbh *VideoBookmarkHandler) GetUserBookmarks(c *gin.Context) {
|
||||
// Parse query parameters
|
||||
limit := 20
|
||||
offset := 0
|
||||
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 && parsed <= 100 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
if offsetStr := c.Query("offset"); offsetStr != "" {
|
||||
if parsed, err := strconv.Atoi(offsetStr); err == nil && parsed >= 0 {
|
||||
offset = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Get user ID from JWT token (for now using demo user ID 1)
|
||||
userID := uint(1)
|
||||
|
||||
bookmarks, err := vbh.bookmarkService.GetUserBookmarks(userID, limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to get bookmarks",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"bookmarks": bookmarks,
|
||||
"count": len(bookmarks),
|
||||
})
|
||||
}
|
||||
|
||||
// GetBookmarkByID gets a specific bookmark
|
||||
func (vbh *VideoBookmarkHandler) GetBookmarkByID(c *gin.Context) {
|
||||
bookmarkID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid bookmark ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Get user ID from JWT token (for now using demo user ID 1)
|
||||
userID := uint(1)
|
||||
|
||||
bookmark, err := vbh.bookmarkService.GetBookmarkByID(userID, uint(bookmarkID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Bookmark not found",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"bookmark": bookmark,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateBookmark updates a bookmark
|
||||
func (vbh *VideoBookmarkHandler) UpdateBookmark(c *gin.Context) {
|
||||
bookmarkID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid bookmark ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req services.SaveVideoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid request format",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Get user ID from JWT token (for now using demo user ID 1)
|
||||
userID := uint(1)
|
||||
|
||||
bookmark, err := vbh.bookmarkService.UpdateBookmark(userID, uint(bookmarkID), req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to update bookmark",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"bookmark": bookmark,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteBookmark deletes a bookmark
|
||||
func (vbh *VideoBookmarkHandler) DeleteBookmark(c *gin.Context) {
|
||||
bookmarkID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid bookmark ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Get user ID from JWT token (for now using demo user ID 1)
|
||||
userID := uint(1)
|
||||
|
||||
if err := vbh.bookmarkService.DeleteBookmark(userID, uint(bookmarkID)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to delete bookmark",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Bookmark deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// ToggleWatched toggles the watched status of a bookmark
|
||||
func (vbh *VideoBookmarkHandler) ToggleWatched(c *gin.Context) {
|
||||
bookmarkID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid bookmark ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Get user ID from JWT token (for now using demo user ID 1)
|
||||
userID := uint(1)
|
||||
|
||||
bookmark, err := vbh.bookmarkService.ToggleWatched(userID, uint(bookmarkID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to toggle watched status",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"bookmark": bookmark,
|
||||
})
|
||||
}
|
||||
|
||||
// ToggleFavorite toggles the favorite status of a bookmark
|
||||
func (vbh *VideoBookmarkHandler) ToggleFavorite(c *gin.Context) {
|
||||
bookmarkID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid bookmark ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Get user ID from JWT token (for now using demo user ID 1)
|
||||
userID := uint(1)
|
||||
|
||||
bookmark, err := vbh.bookmarkService.ToggleFavorite(userID, uint(bookmarkID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to toggle favorite status",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"bookmark": bookmark,
|
||||
})
|
||||
}
|
||||
|
||||
// SearchBookmarks searches bookmarks
|
||||
func (vbh *VideoBookmarkHandler) SearchBookmarks(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Search query is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
limit := 20
|
||||
offset := 0
|
||||
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 && parsed <= 100 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
if offsetStr := c.Query("offset"); offsetStr != "" {
|
||||
if parsed, err := strconv.Atoi(offsetStr); err == nil && parsed >= 0 {
|
||||
offset = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Get user ID from JWT token (for now using demo user ID 1)
|
||||
userID := uint(1)
|
||||
|
||||
bookmarks, err := vbh.bookmarkService.SearchBookmarks(userID, query, limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to search bookmarks",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"bookmarks": bookmarks,
|
||||
"count": len(bookmarks),
|
||||
"query": query,
|
||||
})
|
||||
}
|
||||
|
||||
// GetBookmarkStats gets statistics about user's bookmarks
|
||||
func (vbh *VideoBookmarkHandler) GetBookmarkStats(c *gin.Context) {
|
||||
// TODO: Get user ID from JWT token (for now using demo user ID 1)
|
||||
userID := uint(1)
|
||||
|
||||
stats, err := vbh.bookmarkService.GetBookmarkStats(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to get bookmark stats",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"stats": stats,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,782 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gocolly/colly/v2"
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// WebScrapingHandler handles web scraping operations
|
||||
type WebScrapingHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewWebScrapingHandler creates a new web scraping handler
|
||||
func NewWebScrapingHandler(db *gorm.DB) *WebScrapingHandler {
|
||||
return &WebScrapingHandler{db: db}
|
||||
}
|
||||
|
||||
// CreateScrapingJob creates a new web scraping job
|
||||
func (h *WebScrapingHandler) CreateScrapingJob(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
JobType string `json:"job_type"`
|
||||
Priority string `json:"priority"`
|
||||
ExtractImages bool `json:"extract_images"`
|
||||
ExtractLinks bool `json:"extract_links"`
|
||||
ExtractVideos bool `json:"extract_videos"`
|
||||
GenerateSummary bool `json:"generate_summary"`
|
||||
DownloadImages bool `json:"download_images"`
|
||||
ExtractMetadata bool `json:"extract_metadata"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
if _, err := url.ParseRequestURI(req.URL); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid URL format"})
|
||||
return
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if req.JobType == "" {
|
||||
req.JobType = "full_scrape"
|
||||
}
|
||||
if req.Priority == "" {
|
||||
req.Priority = "normal"
|
||||
}
|
||||
|
||||
job := models.ScrapingJob{
|
||||
UserID: userID,
|
||||
URL: req.URL,
|
||||
JobType: req.JobType,
|
||||
Priority: req.Priority,
|
||||
ExtractImages: req.ExtractImages,
|
||||
ExtractLinks: req.ExtractLinks,
|
||||
ExtractVideos: req.ExtractVideos,
|
||||
GenerateSummary: req.GenerateSummary,
|
||||
DownloadImages: req.DownloadImages,
|
||||
ExtractMetadata: req.ExtractMetadata,
|
||||
Status: "pending",
|
||||
}
|
||||
|
||||
if err := h.db.Create(&job).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create scraping job"})
|
||||
return
|
||||
}
|
||||
|
||||
// Start processing the job asynchronously
|
||||
go h.processScrapingJob(job.ID)
|
||||
|
||||
c.JSON(http.StatusCreated, job)
|
||||
}
|
||||
|
||||
// GetScrapingJobs returns user's scraping jobs
|
||||
func (h *WebScrapingHandler) GetScrapingJobs(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
status := c.Query("status")
|
||||
limit := 20
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
query := h.db.Where("user_id = ?", userID)
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
var jobs []models.ScrapingJob
|
||||
if err := query.Order("created_at DESC").Limit(limit).Find(&jobs).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch scraping jobs"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"jobs": jobs,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// GetScrapingJob returns a specific scraping job
|
||||
func (h *WebScrapingHandler) GetScrapingJob(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
jobID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid job ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var job models.ScrapingJob
|
||||
if err := h.db.Where("id = ? AND user_id = ?", jobID, userID).
|
||||
Preload("ScrapedContent").
|
||||
First(&job).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Scraping job not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, job)
|
||||
}
|
||||
|
||||
// GetScrapedContent returns scraped content
|
||||
func (h *WebScrapingHandler) GetScrapedContent(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
contentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid content ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var content models.ScrapedContent
|
||||
if err := h.db.Where("id = ? AND user_id = ?", contentID, userID).
|
||||
Preload("Images").
|
||||
Preload("Links").
|
||||
Preload("Videos").
|
||||
Preload("Tags").
|
||||
First(&content).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Scraped content not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
|
||||
// GetScrapedContentList returns user's scraped content
|
||||
func (h *WebScrapingHandler) GetScrapedContentList(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
contentType := c.Query("content_type")
|
||||
domain := c.Query("domain")
|
||||
limit := 20
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
query := h.db.Where("user_id = ?", userID)
|
||||
if contentType != "" {
|
||||
query = query.Where("content_type = ?", contentType)
|
||||
}
|
||||
if domain != "" {
|
||||
query = query.Where("domain = ?", domain)
|
||||
}
|
||||
|
||||
var content []models.ScrapedContent
|
||||
if err := query.Order("last_scraped DESC").Limit(limit).Find(&content).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch scraped content"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"content": content,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteScrapingJob deletes a scraping job
|
||||
func (h *WebScrapingHandler) DeleteScrapingJob(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
jobID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid job ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var job models.ScrapingJob
|
||||
if err := h.db.Where("id = ? AND user_id = ?", jobID, userID).First(&job).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Scraping job not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Only allow deletion of pending, completed, or failed jobs
|
||||
if job.Status == "processing" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete job that is currently processing"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Delete(&job).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete scraping job"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Scraping job deleted successfully"})
|
||||
}
|
||||
|
||||
// DeleteScrapedContent deletes scraped content
|
||||
func (h *WebScrapingHandler) DeleteScrapedContent(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
contentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid content ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var content models.ScrapedContent
|
||||
if err := h.db.Where("id = ? AND user_id = ?", contentID, userID).First(&content).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Scraped content not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Delete(&content).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete scraped content"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Scraped content deleted successfully"})
|
||||
}
|
||||
|
||||
// SearchScrapedContent searches within scraped content
|
||||
func (h *WebScrapingHandler) SearchScrapedContent(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Search query is required"})
|
||||
return
|
||||
}
|
||||
|
||||
contentType := c.Query("content_type")
|
||||
domain := c.Query("domain")
|
||||
limit := 20
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Build search query
|
||||
dbQuery := h.db.Where("user_id = ?", userID)
|
||||
|
||||
// Search in title, content, and description
|
||||
searchCondition := h.db.Where("title ILIKE ?", "%"+query+"%").
|
||||
Or("content ILIKE ?", "%"+query+"%").
|
||||
Or("description ILIKE ?", "%"+query+"%")
|
||||
|
||||
dbQuery = dbQuery.Where(searchCondition)
|
||||
|
||||
if contentType != "" {
|
||||
dbQuery = dbQuery.Where("content_type = ?", contentType)
|
||||
}
|
||||
if domain != "" {
|
||||
dbQuery = dbQuery.Where("domain = ?", domain)
|
||||
}
|
||||
|
||||
var content []models.ScrapedContent
|
||||
if err := dbQuery.Order("last_scraped DESC").Limit(limit).Find(&content).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search scraped content"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"content": content,
|
||||
"query": query,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// processScrapingJob processes a scraping job asynchronously
|
||||
func (h *WebScrapingHandler) processScrapingJob(jobID uint) {
|
||||
var job models.ScrapingJob
|
||||
if err := h.db.First(&job, jobID).Error; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Update job status to processing
|
||||
now := time.Now()
|
||||
job.Status = "processing"
|
||||
job.StartedAt = &now
|
||||
h.db.Save(&job)
|
||||
|
||||
// Perform the scraping
|
||||
scrapedContent, err := h.scrapeWebPage(job.URL, job)
|
||||
if err != nil {
|
||||
job.Status = "failed"
|
||||
job.ErrorMessage = err.Error()
|
||||
completedAt := time.Now()
|
||||
job.CompletedAt = &completedAt
|
||||
h.db.Save(&job)
|
||||
return
|
||||
}
|
||||
|
||||
// Update job with results
|
||||
job.Status = "completed"
|
||||
job.ScrapedContentID = &scrapedContent.ID
|
||||
job.Progress = 100
|
||||
completedAt := time.Now()
|
||||
job.CompletedAt = &completedAt
|
||||
h.db.Save(&job)
|
||||
}
|
||||
|
||||
// scrapeWebPage scrapes a web page and extracts content
|
||||
func (h *WebScrapingHandler) scrapeWebPage(pageURL string, job models.ScrapingJob) (*models.ScrapedContent, error) {
|
||||
parsedURL, err := url.Parse(pageURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
// Create a new collector
|
||||
c := colly.NewCollector(
|
||||
colly.AllowURLRevisit(),
|
||||
colly.Async(true),
|
||||
)
|
||||
|
||||
// Set up content extraction variables
|
||||
var title, description, content string
|
||||
var keywords []string
|
||||
var images []models.ScrapedImage
|
||||
var links []models.ScrapedLink
|
||||
var videos []models.ScrapedVideo
|
||||
|
||||
// Extract title
|
||||
c.OnHTML("title", func(e *colly.HTMLElement) {
|
||||
title = strings.TrimSpace(e.Text)
|
||||
})
|
||||
|
||||
// Extract meta description
|
||||
c.OnHTML("meta[name='description']", func(e *colly.HTMLElement) {
|
||||
if description == "" {
|
||||
description = e.Attr("content")
|
||||
}
|
||||
})
|
||||
|
||||
// Extract meta keywords
|
||||
c.OnHTML("meta[name='keywords']", func(e *colly.HTMLElement) {
|
||||
if len(keywords) == 0 {
|
||||
keywordsStr := e.Attr("content")
|
||||
if keywordsStr != "" {
|
||||
keywords = strings.Split(keywordsStr, ",")
|
||||
for i, kw := range keywords {
|
||||
keywords[i] = strings.TrimSpace(kw)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Extract main content
|
||||
c.OnHTML("article, main, .content, .post-content, .entry-content", func(e *colly.HTMLElement) {
|
||||
content = strings.TrimSpace(e.Text)
|
||||
})
|
||||
|
||||
// Fallback to body content if no specific content found
|
||||
c.OnHTML("body", func(e *colly.HTMLElement) {
|
||||
if content == "" {
|
||||
content = strings.TrimSpace(e.Text)
|
||||
}
|
||||
})
|
||||
|
||||
// Extract images if requested
|
||||
if job.ExtractImages {
|
||||
c.OnHTML("img", func(e *colly.HTMLElement) {
|
||||
src := e.Attr("src")
|
||||
alt := e.Attr("alt")
|
||||
|
||||
// Convert relative URLs to absolute
|
||||
if src != "" {
|
||||
if strings.HasPrefix(src, "/") {
|
||||
src = parsedURL.Scheme + "://" + parsedURL.Host + src
|
||||
} else if !strings.HasPrefix(src, "http") {
|
||||
src = parsedURL.Scheme + "://" + parsedURL.Host + "/" + src
|
||||
}
|
||||
|
||||
images = append(images, models.ScrapedImage{
|
||||
URL: src,
|
||||
AltText: alt,
|
||||
Format: h.getImageFormat(src),
|
||||
IsMainImage: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Extract links if requested
|
||||
if job.ExtractLinks {
|
||||
c.OnHTML("a[href]", func(e *colly.HTMLElement) {
|
||||
href := e.Attr("href")
|
||||
text := strings.TrimSpace(e.Text)
|
||||
|
||||
if href != "" && text != "" {
|
||||
// Convert relative URLs to absolute
|
||||
if strings.HasPrefix(href, "/") {
|
||||
href = parsedURL.Scheme + "://" + parsedURL.Host + href
|
||||
}
|
||||
|
||||
linkType := "external"
|
||||
if strings.Contains(href, parsedURL.Host) {
|
||||
linkType = "internal"
|
||||
}
|
||||
|
||||
links = append(links, models.ScrapedLink{
|
||||
URL: href,
|
||||
Text: text,
|
||||
LinkType: linkType,
|
||||
Domain: h.getDomainFromURL(href),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Extract videos if requested
|
||||
if job.ExtractVideos {
|
||||
c.OnHTML("iframe[src], video source", func(e *colly.HTMLElement) {
|
||||
src := e.Attr("src")
|
||||
title := e.Attr("title")
|
||||
|
||||
if src != "" {
|
||||
platform := h.getVideoPlatform(src)
|
||||
videos = append(videos, models.ScrapedVideo{
|
||||
URL: src,
|
||||
Title: title,
|
||||
Platform: platform,
|
||||
VideoID: h.getVideoID(src, platform),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Set error handler
|
||||
c.OnError(func(r *colly.Response, err error) {
|
||||
fmt.Printf("Error scraping %s: %v\n", r.Request.URL, err)
|
||||
})
|
||||
|
||||
// Start scraping
|
||||
err = c.Visit(pageURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to visit page: %w", err)
|
||||
}
|
||||
|
||||
c.Wait()
|
||||
|
||||
// Clean and process content
|
||||
if content == "" {
|
||||
content = "No content could be extracted from this page."
|
||||
}
|
||||
|
||||
if description == "" {
|
||||
description = content
|
||||
if len(description) > 200 {
|
||||
description = description[:200] + "..."
|
||||
}
|
||||
}
|
||||
|
||||
// Generate keywords if none found
|
||||
if len(keywords) == 0 && job.ExtractMetadata {
|
||||
keywords = h.extractKeywordsFromContent(content)
|
||||
}
|
||||
|
||||
// Create the scraped content
|
||||
scrapedContent := models.ScrapedContent{
|
||||
UserID: job.UserID,
|
||||
URL: pageURL,
|
||||
Domain: parsedURL.Hostname(),
|
||||
Title: title,
|
||||
Description: description,
|
||||
Content: content,
|
||||
Keywords: keywords,
|
||||
ContentType: h.detectContentType(title, content),
|
||||
WordCount: len(strings.Fields(content)),
|
||||
ReadingTime: h.estimateReadingTime(len(strings.Fields(content))),
|
||||
QualityScore: 0, // Will be calculated below
|
||||
Status: "completed",
|
||||
LastScraped: time.Now(),
|
||||
}
|
||||
|
||||
// Generate summary if requested
|
||||
if job.GenerateSummary {
|
||||
scrapedContent.Summary = h.generateSummary(content)
|
||||
}
|
||||
|
||||
// Create the content in database
|
||||
if err := h.db.Create(&scrapedContent).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to save scraped content: %w", err)
|
||||
}
|
||||
|
||||
// Save related content
|
||||
if len(images) > 0 {
|
||||
for i := range images {
|
||||
images[i].ScrapedContentID = scrapedContent.ID
|
||||
}
|
||||
h.db.Create(&images)
|
||||
}
|
||||
|
||||
if len(links) > 0 {
|
||||
for i := range links {
|
||||
links[i].ScrapedContentID = scrapedContent.ID
|
||||
}
|
||||
h.db.Create(&links)
|
||||
}
|
||||
|
||||
if len(videos) > 0 {
|
||||
for i := range videos {
|
||||
videos[i].ScrapedContentID = scrapedContent.ID
|
||||
}
|
||||
h.db.Create(&videos)
|
||||
}
|
||||
|
||||
// Calculate and save quality score
|
||||
scrapedContent.QualityScore = h.calculateQualityScore(scrapedContent)
|
||||
h.db.Save(&scrapedContent)
|
||||
|
||||
return &scrapedContent, nil
|
||||
}
|
||||
|
||||
// extractTextFromHTML extracts text content from HTML
|
||||
func (h *WebScrapingHandler) extractTextFromHTML(html string) string {
|
||||
// Remove HTML tags
|
||||
re := regexp.MustCompile(`<[^>]*>`)
|
||||
text := re.ReplaceAllString(html, "")
|
||||
|
||||
// Clean up whitespace
|
||||
text = strings.TrimSpace(text)
|
||||
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// estimateReadingTime estimates reading time in minutes
|
||||
func (h *WebScrapingHandler) estimateReadingTime(wordCount int) int {
|
||||
// Average reading speed: 200-250 words per minute
|
||||
readingSpeed := 225
|
||||
readingTime := wordCount / readingSpeed
|
||||
if readingTime < 1 {
|
||||
readingTime = 1
|
||||
}
|
||||
return readingTime
|
||||
}
|
||||
|
||||
// calculateQualityScore calculates a quality score for the content
|
||||
func (h *WebScrapingHandler) calculateQualityScore(content models.ScrapedContent) float64 {
|
||||
score := 50.0 // Base score
|
||||
|
||||
// Add points for having title
|
||||
if content.Title != "" {
|
||||
score += 10
|
||||
}
|
||||
|
||||
// Add points for content length
|
||||
if content.WordCount > 100 {
|
||||
score += 10
|
||||
}
|
||||
if content.WordCount > 500 {
|
||||
score += 10
|
||||
}
|
||||
|
||||
// Add points for having description
|
||||
if content.Description != "" {
|
||||
score += 10
|
||||
}
|
||||
|
||||
// Add points for having images
|
||||
if len(content.Images) > 0 {
|
||||
score += 5
|
||||
}
|
||||
|
||||
// Add points for having keywords
|
||||
if len(content.Keywords) > 0 {
|
||||
score += 5
|
||||
}
|
||||
|
||||
// Cap at 100
|
||||
if score > 100 {
|
||||
score = 100
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
// Helper methods for web scraping
|
||||
|
||||
// getImageFormat extracts image format from URL
|
||||
func (h *WebScrapingHandler) getImageFormat(url string) string {
|
||||
lower := strings.ToLower(url)
|
||||
if strings.HasSuffix(lower, ".jpg") || strings.HasSuffix(lower, ".jpeg") {
|
||||
return "jpg"
|
||||
} else if strings.HasSuffix(lower, ".png") {
|
||||
return "png"
|
||||
} else if strings.HasSuffix(lower, ".gif") {
|
||||
return "gif"
|
||||
} else if strings.HasSuffix(lower, ".svg") {
|
||||
return "svg"
|
||||
} else if strings.HasSuffix(lower, ".webp") {
|
||||
return "webp"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// getDomainFromURL extracts domain from URL
|
||||
func (h *WebScrapingHandler) getDomainFromURL(urlStr string) string {
|
||||
if parsedURL, err := url.Parse(urlStr); err == nil {
|
||||
return parsedURL.Hostname()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getVideoPlatform detects video platform from URL
|
||||
func (h *WebScrapingHandler) getVideoPlatform(urlStr string) string {
|
||||
lower := strings.ToLower(urlStr)
|
||||
if strings.Contains(lower, "youtube.com") || strings.Contains(lower, "youtu.be") {
|
||||
return "youtube"
|
||||
} else if strings.Contains(lower, "vimeo.com") {
|
||||
return "vimeo"
|
||||
} else if strings.Contains(lower, "twitch.tv") {
|
||||
return "twitch"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// getVideoID extracts video ID from URL
|
||||
func (h *WebScrapingHandler) getVideoID(urlStr, platform string) string {
|
||||
switch platform {
|
||||
case "youtube":
|
||||
if strings.Contains(urlStr, "youtube.com/watch?v=") {
|
||||
parts := strings.Split(urlStr, "v=")
|
||||
if len(parts) > 1 {
|
||||
id := strings.Split(parts[1], "&")[0]
|
||||
return id
|
||||
}
|
||||
} else if strings.Contains(urlStr, "youtu.be/") {
|
||||
parts := strings.Split(urlStr, "youtu.be/")
|
||||
if len(parts) > 1 {
|
||||
return strings.Split(parts[1], "?")[0]
|
||||
}
|
||||
}
|
||||
case "vimeo":
|
||||
parts := strings.Split(urlStr, "vimeo.com/")
|
||||
if len(parts) > 1 {
|
||||
return strings.Split(parts[1], "?")[0]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractKeywordsFromContent extracts keywords from content
|
||||
func (h *WebScrapingHandler) extractKeywordsFromContent(content string) []string {
|
||||
// Simple keyword extraction - in production, you'd use more sophisticated NLP
|
||||
words := strings.Fields(strings.ToLower(content))
|
||||
wordCount := make(map[string]int)
|
||||
|
||||
// Count word frequency
|
||||
for _, word := range words {
|
||||
// Filter out common words
|
||||
if len(word) > 3 && !h.isCommonWord(word) {
|
||||
wordCount[word]++
|
||||
}
|
||||
}
|
||||
|
||||
// Get top keywords
|
||||
type wordFreq struct {
|
||||
word string
|
||||
count int
|
||||
}
|
||||
|
||||
var sortedWords []wordFreq
|
||||
for word, count := range wordCount {
|
||||
if count > 1 { // Only include words that appear more than once
|
||||
sortedWords = append(sortedWords, wordFreq{word, count})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by frequency
|
||||
for i := 0; i < len(sortedWords)-1; i++ {
|
||||
for j := i + 1; j < len(sortedWords); j++ {
|
||||
if sortedWords[j].count > sortedWords[i].count {
|
||||
sortedWords[i], sortedWords[j] = sortedWords[j], sortedWords[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return top 10 keywords
|
||||
var keywords []string
|
||||
for i := 0; i < len(sortedWords) && i < 10; i++ {
|
||||
keywords = append(keywords, sortedWords[i].word)
|
||||
}
|
||||
|
||||
return keywords
|
||||
}
|
||||
|
||||
// isCommonWord checks if a word is too common to be a keyword
|
||||
func (h *WebScrapingHandler) isCommonWord(word string) bool {
|
||||
commonWords := []string{
|
||||
"the", "and", "for", "are", "but", "not", "you", "all", "can", "had", "her", "was", "one", "our", "out", "day", "get", "has", "him", "his", "how", "man", "new", "now", "old", "see", "two", "way", "who", "boy", "did", "its", "let", "put", "say", "she", "too", "use", "with", "have", "this", "that", "from", "they", "been", "call", "come", "each", "find", "give", "hand", "keep", "know", "last", "leave", "life", "long", "made", "many", "move", "must", "name", "need", "only", "over", "part", "said", "same", "show", "tell", "time", "turn", "well", "went", "were", "what", "will", "your", "about", "after", "again", "before", "being", "below", "could", "every", "first", "found", "great", "house", "large", "never", "other", "place", "right", "small", "sound", "still", "their", "there", "think", "under", "water", "where", "which", "world", "would", "write", "years",
|
||||
}
|
||||
|
||||
for _, common := range commonWords {
|
||||
if word == common {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// detectContentType detects the type of content
|
||||
func (h *WebScrapingHandler) detectContentType(title, content string) string {
|
||||
titleLower := strings.ToLower(title)
|
||||
contentLower := strings.ToLower(content)
|
||||
|
||||
// Check for tutorial
|
||||
if strings.Contains(titleLower, "tutorial") || strings.Contains(titleLower, "how to") || strings.Contains(contentLower, "step by step") {
|
||||
return "tutorial"
|
||||
}
|
||||
|
||||
// Check for documentation
|
||||
if strings.Contains(titleLower, "documentation") || strings.Contains(titleLower, "api") || strings.Contains(contentLower, "function") {
|
||||
return "documentation"
|
||||
}
|
||||
|
||||
// Check for news
|
||||
if strings.Contains(titleLower, "news") || strings.Contains(contentLower, "breaking") || strings.Contains(contentLower, "report") {
|
||||
return "news"
|
||||
}
|
||||
|
||||
// Check for blog
|
||||
if strings.Contains(titleLower, "blog") || strings.Contains(contentLower, "posted") || strings.Contains(contentLower, "opinion") {
|
||||
return "blog"
|
||||
}
|
||||
|
||||
// Default to article
|
||||
return "article"
|
||||
}
|
||||
|
||||
// generateSummary generates a simple summary
|
||||
func (h *WebScrapingHandler) generateSummary(content string) string {
|
||||
sentences := strings.Split(content, ".")
|
||||
if len(sentences) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Take first 2-3 sentences as summary
|
||||
summaryLength := 2
|
||||
if len(sentences) < 2 {
|
||||
summaryLength = len(sentences)
|
||||
} else if len(sentences) > 3 {
|
||||
summaryLength = 3
|
||||
}
|
||||
|
||||
var summary string
|
||||
for i := 0; i < summaryLength; i++ {
|
||||
sentence := strings.TrimSpace(sentences[i])
|
||||
if sentence != "" {
|
||||
summary += sentence + ". "
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(summary)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/services"
|
||||
)
|
||||
|
||||
// YouTubeSearchRequest represents the request for YouTube search
|
||||
type YouTubeSearchRequest struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
MaxResults int `json:"max_results"`
|
||||
PageToken string `json:"page_token"`
|
||||
}
|
||||
|
||||
// YouTubeVideoDetailsRequest represents the request for video details
|
||||
type YouTubeVideoDetailsRequest struct {
|
||||
VideoID string `json:"video_id" binding:"required"`
|
||||
}
|
||||
|
||||
// YouTubeChannelVideosRequest represents the request for channel videos
|
||||
type YouTubeChannelVideosRequest struct {
|
||||
ChannelID string `json:"channel_id" binding:"required"`
|
||||
MaxResults int `json:"max_results"`
|
||||
PageToken string `json:"page_token"`
|
||||
}
|
||||
|
||||
// SearchYouTube handles POST /api/v1/youtube/search
|
||||
func SearchYouTube(c *gin.Context) {
|
||||
var req YouTubeSearchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set default max results and enforce an upper limit of 9 per request
|
||||
if req.MaxResults <= 0 || req.MaxResults > 9 {
|
||||
req.MaxResults = 9
|
||||
}
|
||||
|
||||
// Search videos using the YouTube service
|
||||
response, err := services.SearchYouTubeVideos(req.Query, req.MaxResults, req.PageToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to search YouTube videos",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetYouTubeVideoDetails handles POST /api/v1/youtube/video-details
|
||||
func GetYouTubeVideoDetails(c *gin.Context) {
|
||||
var req YouTubeVideoDetailsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get video details using the YouTube service
|
||||
video, err := services.GetYouTubeVideoDetails(req.VideoID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to get video details",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, video)
|
||||
}
|
||||
|
||||
// YouTubeChannelURLRequest represents the request for channel videos from URL
|
||||
type YouTubeChannelURLRequest struct {
|
||||
ChannelURL string `json:"channel_url" binding:"required"`
|
||||
MaxResults int `json:"max_results"`
|
||||
}
|
||||
|
||||
// GetYouTubeChannelVideosFromURL handles POST /api/v1/youtube/channel-from-url
|
||||
func GetYouTubeChannelVideosFromURL(c *gin.Context) {
|
||||
var req YouTubeChannelURLRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set default max results if not provided
|
||||
if req.MaxResults <= 0 {
|
||||
req.MaxResults = 20
|
||||
}
|
||||
if req.MaxResults > 50 {
|
||||
req.MaxResults = 50
|
||||
}
|
||||
|
||||
// Get channel videos using the new service method
|
||||
youtubeService := services.NewYouTubeService()
|
||||
response, err := youtubeService.GetChannelVideosFromURL(req.ChannelURL, req.MaxResults)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to fetch channel videos from URL",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetYouTubeChannelVideos handles POST /api/v1/youtube/channel-videos (legacy)
|
||||
func GetYouTubeChannelVideos(c *gin.Context) {
|
||||
var req YouTubeChannelVideosRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set default max results if not provided
|
||||
if req.MaxResults == 0 {
|
||||
req.MaxResults = 10
|
||||
}
|
||||
|
||||
// Validate max results
|
||||
if req.MaxResults < 1 || req.MaxResults > 50 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "max_results must be between 1 and 50"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get channel videos using the YouTube service
|
||||
response, err := services.GetYouTubeChannelVideos(req.ChannelID, req.MaxResults, req.PageToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to get channel videos",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetYouTubeTrending handles GET /api/v1/youtube/trending
|
||||
func GetYouTubeTrending(c *gin.Context) {
|
||||
// Get query parameters
|
||||
category := c.Query("category") // Optional: music, gaming, news, etc.
|
||||
maxResults, _ := strconv.Atoi(c.DefaultQuery("max_results", "9"))
|
||||
|
||||
// Enforce 1-9 range
|
||||
if maxResults < 1 || maxResults > 9 {
|
||||
maxResults = 9
|
||||
}
|
||||
|
||||
// Search for trending videos with category-specific queries
|
||||
query := "trending videos"
|
||||
if category != "" {
|
||||
query = "trending " + category + " videos"
|
||||
}
|
||||
|
||||
response, err := services.SearchYouTubeVideos(query, maxResults, "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to get trending videos",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetPredefinedChannelVideos handles GET /api/v1/youtube/predefined-channels
|
||||
func GetPredefinedChannelVideos(c *gin.Context) {
|
||||
// Get query parameters
|
||||
maxResults, _ := strconv.Atoi(c.DefaultQuery("max_results", "5"))
|
||||
|
||||
// Validate max results
|
||||
if maxResults < 1 || maxResults > 20 {
|
||||
maxResults = 10
|
||||
}
|
||||
|
||||
// Get videos from predefined channels
|
||||
response, err := services.GetPredefinedChannelVideos(maxResults)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to get predefined channel videos",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/services"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// YouTubeChannelRequest represents the request for channel videos
|
||||
type YouTubeChannelRequest struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
MaxResults int `json:"max_results"`
|
||||
}
|
||||
|
||||
// GetFireshipVideos fetches latest videos from Fireship channel
|
||||
func GetFireshipVideos(c *gin.Context) {
|
||||
// Get max results from query parameter (default: 20)
|
||||
maxResults := 20
|
||||
if maxResultsStr := c.Query("max_results"); maxResultsStr != "" {
|
||||
if parsed, err := strconv.Atoi(maxResultsStr); err == nil && parsed > 0 && parsed <= 50 {
|
||||
maxResults = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Create YouTube channel service with cache
|
||||
var db *gorm.DB
|
||||
if config.GetDB() != nil {
|
||||
db = config.GetDB()
|
||||
}
|
||||
youtubeService := services.NewYouTubeService()
|
||||
cacheService := services.NewYouTubeCacheService(db)
|
||||
channelService := services.NewYouTubeChannelService(youtubeService, cacheService)
|
||||
|
||||
// Fetch Fireship videos
|
||||
videos, err := channelService.GetFireshipVideos(maxResults)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to fetch Fireship videos",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"channel": "Fireship",
|
||||
"videos": videos,
|
||||
"count": len(videos),
|
||||
})
|
||||
}
|
||||
|
||||
// GetNetworkChuckVideos fetches latest videos from Network Chuck channel
|
||||
func GetNetworkChuckVideos(c *gin.Context) {
|
||||
// Get max results from query parameter (default: 20)
|
||||
maxResults := 20
|
||||
if maxResultsStr := c.Query("max_results"); maxResultsStr != "" {
|
||||
if parsed, err := strconv.Atoi(maxResultsStr); err == nil && parsed > 0 && parsed <= 50 {
|
||||
maxResults = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Create YouTube channel service with cache
|
||||
var db *gorm.DB
|
||||
if config.GetDB() != nil {
|
||||
db = config.GetDB()
|
||||
}
|
||||
youtubeService := services.NewYouTubeService()
|
||||
cacheService := services.NewYouTubeCacheService(db)
|
||||
channelService := services.NewYouTubeChannelService(youtubeService, cacheService)
|
||||
|
||||
// Fetch Network Chuck videos
|
||||
videos, err := channelService.GetNetworkChuckVideos(maxResults)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to fetch Network Chuck videos",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"channel": "Network Chuck",
|
||||
"videos": videos,
|
||||
"count": len(videos),
|
||||
})
|
||||
}
|
||||
|
||||
// GetChannelVideos fetches videos from a specific channel
|
||||
func GetChannelVideos(c *gin.Context) {
|
||||
var req YouTubeChannelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid request body",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Set default max results if not provided
|
||||
if req.MaxResults <= 0 {
|
||||
req.MaxResults = 20
|
||||
}
|
||||
if req.MaxResults > 50 {
|
||||
req.MaxResults = 50
|
||||
}
|
||||
|
||||
// Create YouTube channel service with cache
|
||||
var db *gorm.DB
|
||||
if config.GetDB() != nil {
|
||||
db = config.GetDB()
|
||||
}
|
||||
youtubeService := services.NewYouTubeService()
|
||||
cacheService := services.NewYouTubeCacheService(db)
|
||||
channelService := services.NewYouTubeChannelService(youtubeService, cacheService)
|
||||
|
||||
// Get channel info first
|
||||
channelInfo, err := channelService.GetChannelInfo(req.ChannelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Channel not found",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch channel videos
|
||||
response, err := channelService.YouTubeService.GetChannelVideos(req.ChannelID, req.MaxResults, "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to fetch channel videos",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"channel": channelInfo,
|
||||
"videos": response.Videos,
|
||||
"count": len(response.Videos),
|
||||
"next_page_token": response.NextPageToken,
|
||||
})
|
||||
}
|
||||
Binary file not shown.
+832
-86
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,414 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// AuditMiddleware creates audit logs for HTTP requests
|
||||
func AuditMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Read request body for logging (only for POST/PUT/PATCH)
|
||||
var requestBody []byte
|
||||
if c.Request.Method != "GET" && c.Request.Body != nil {
|
||||
requestBody, _ = io.ReadAll(c.Request.Body)
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
|
||||
}
|
||||
|
||||
// Process the request
|
||||
c.Next()
|
||||
|
||||
// Skip audit logging for certain endpoints
|
||||
if shouldSkipAudit(c.Request.URL.Path) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create audit log entry with proper user data from session
|
||||
userIDValue := GetUserIDFromSession(c)
|
||||
userEmail := GetUserEmailFromSession(c)
|
||||
|
||||
// Ensure we have valid user data before creating audit log
|
||||
if userIDValue == 0 && userEmail == "unknown" {
|
||||
// Skip audit logging for unauthenticated requests
|
||||
return
|
||||
}
|
||||
|
||||
auditLog := &models.AuditLog{
|
||||
UserID: userIDValue,
|
||||
UserEmail: userEmail,
|
||||
UserIP: c.ClientIP(),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
Action: getActionFromMethodAndPath(c.Request.Method, c.Request.URL.Path),
|
||||
Resource: getResourceFromPath(c.Request.URL.Path),
|
||||
ResourceID: getResourceIDFromPath(c.Request.URL.Path),
|
||||
Description: generateDescription(c, startTime),
|
||||
Details: generateDetails(c, requestBody, startTime),
|
||||
Success: c.Writer.Status() < 400,
|
||||
SessionID: getSessionID(c),
|
||||
Country: getCountryFromIP(c.ClientIP()),
|
||||
Device: getDeviceFromUserAgent(c.Request.UserAgent()),
|
||||
Platform: getPlatformFromUserAgent(c.Request.UserAgent()),
|
||||
Browser: getBrowserFromUserAgent(c.Request.UserAgent()),
|
||||
RiskLevel: assessRisk(c, startTime),
|
||||
}
|
||||
|
||||
// Set failure reason if request failed
|
||||
if !auditLog.Success {
|
||||
auditLog.FailureReason = getFailureReason(c.Writer.Status())
|
||||
}
|
||||
|
||||
// Save audit log asynchronously
|
||||
go saveAuditLog(auditLog)
|
||||
}
|
||||
}
|
||||
|
||||
// LogSecurityEvent logs security-related events
|
||||
func LogSecurityEvent(userID uint, userEmail, action, description, failureReason string, details map[string]interface{}) {
|
||||
auditLog := &models.AuditLog{
|
||||
UserID: userID,
|
||||
UserEmail: userEmail,
|
||||
Action: models.AuditAction(action),
|
||||
Resource: models.AuditResourceSecurity,
|
||||
Description: description,
|
||||
Details: details,
|
||||
Success: failureReason == "",
|
||||
FailureReason: failureReason,
|
||||
RiskLevel: assessSecurityRisk(action, failureReason),
|
||||
Suspicious: isSuspiciousActivity(action, failureReason),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
go saveAuditLog(auditLog)
|
||||
}
|
||||
|
||||
// LogUserAction logs user-specific actions
|
||||
func LogUserAction(user models.User, action models.AuditAction, resource models.AuditResource, resourceID *uint, description string, oldValues, newValues map[string]interface{}) {
|
||||
auditLog := &models.AuditLog{
|
||||
UserID: user.ID,
|
||||
UserEmail: user.Email,
|
||||
Action: action,
|
||||
Resource: resource,
|
||||
ResourceID: resourceID,
|
||||
Description: description,
|
||||
OldValues: oldValues,
|
||||
NewValues: newValues,
|
||||
Success: true,
|
||||
RiskLevel: assessActionRisk(action, resource),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
go saveAuditLog(auditLog)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func shouldSkipAudit(path string) bool {
|
||||
skipPaths := []string{
|
||||
"/health",
|
||||
"/metrics",
|
||||
"/api/demo/status",
|
||||
"/favicon.ico",
|
||||
"/assets/",
|
||||
}
|
||||
|
||||
for _, skipPath := range skipPaths {
|
||||
if strings.HasPrefix(path, skipPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getUintFromInterface(value interface{}) uint {
|
||||
if v, ok := value.(uint); ok {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func getUserEmail(user interface{}) string {
|
||||
if u, ok := user.(models.User); ok {
|
||||
return u.Email
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func getActionFromMethodAndPath(method, path string) models.AuditAction {
|
||||
switch method {
|
||||
case "GET":
|
||||
return models.AuditActionRead
|
||||
case "POST":
|
||||
if strings.Contains(path, "/login") {
|
||||
return models.AuditActionLogin
|
||||
} else if strings.Contains(path, "/logout") {
|
||||
return models.AuditActionLogout
|
||||
} else if strings.Contains(path, "/upload") {
|
||||
return models.AuditActionUpload
|
||||
} else if strings.Contains(path, "/export") {
|
||||
return models.AuditActionExport
|
||||
} else if strings.Contains(path, "/import") {
|
||||
return models.AuditActionImport
|
||||
}
|
||||
return models.AuditActionCreate
|
||||
case "PUT", "PATCH":
|
||||
return models.AuditActionUpdate
|
||||
case "DELETE":
|
||||
return models.AuditActionDelete
|
||||
default:
|
||||
return models.AuditActionAccess
|
||||
}
|
||||
}
|
||||
|
||||
func getResourceFromPath(path string) models.AuditResource {
|
||||
if strings.Contains(path, "/users") {
|
||||
return models.AuditResourceUser
|
||||
} else if strings.Contains(path, "/notes") {
|
||||
return models.AuditResourceNote
|
||||
} else if strings.Contains(path, "/files") {
|
||||
return models.AuditResourceFile
|
||||
} else if strings.Contains(path, "/bookmarks") {
|
||||
return models.AuditResourceBookmark
|
||||
} else if strings.Contains(path, "/tasks") {
|
||||
return models.AuditResourceTask
|
||||
} else if strings.Contains(path, "/time-entries") {
|
||||
return models.AuditResourceTimeEntry
|
||||
} else if strings.Contains(path, "/integrations") {
|
||||
return models.AuditResourceIntegration
|
||||
} else if strings.Contains(path, "/teams") {
|
||||
return models.AuditResourceTeam
|
||||
} else if strings.Contains(path, "/goals") || strings.Contains(path, "/habits") {
|
||||
return models.AuditResourceGoal
|
||||
} else if strings.Contains(path, "/calendar") {
|
||||
return models.AuditResourceCalendar
|
||||
} else if strings.Contains(path, "/search") {
|
||||
return models.AuditResourceSearch
|
||||
} else if strings.Contains(path, "/ai") {
|
||||
return models.AuditResourceAI
|
||||
} else if strings.Contains(path, "/analytics") {
|
||||
return models.AuditResourceAnalytics
|
||||
} else if strings.Contains(path, "/auth") {
|
||||
return models.AuditResourceSecurity
|
||||
}
|
||||
return models.AuditResourceSystem
|
||||
}
|
||||
|
||||
func getResourceIDFromPath(path string) *uint {
|
||||
parts := strings.Split(path, "/")
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
// Check if this part looks like a numeric ID
|
||||
if i > 0 && len(part) >= 1 && part[0] >= '0' && part[0] <= '9' {
|
||||
var id uint
|
||||
if _, err := fmt.Sscanf(part, "%d", &id); err == nil {
|
||||
return &id
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateDescription(c *gin.Context, startTime time.Time) string {
|
||||
duration := time.Since(startTime)
|
||||
method := c.Request.Method
|
||||
path := c.Request.URL.Path
|
||||
status := c.Writer.Status()
|
||||
|
||||
return fmt.Sprintf("%s %s - %d (%v)", method, path, status, duration.Round(time.Millisecond))
|
||||
}
|
||||
|
||||
func generateDetails(c *gin.Context, requestBody []byte, startTime time.Time) map[string]interface{} {
|
||||
details := make(map[string]interface{})
|
||||
|
||||
details["method"] = c.Request.Method
|
||||
details["path"] = c.Request.URL.Path
|
||||
details["query"] = c.Request.URL.RawQuery
|
||||
details["status_code"] = c.Writer.Status()
|
||||
details["duration_ms"] = time.Since(startTime).Milliseconds()
|
||||
details["response_size"] = c.Writer.Size()
|
||||
|
||||
if len(requestBody) > 0 && len(requestBody) < 1024 { // Only log small request bodies
|
||||
var jsonBody map[string]interface{}
|
||||
if err := json.Unmarshal(requestBody, &jsonBody); err == nil {
|
||||
// Remove sensitive fields
|
||||
sanitizeJSON(jsonBody)
|
||||
details["request_body"] = jsonBody
|
||||
}
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
func sanitizeJSON(data map[string]interface{}) {
|
||||
sensitiveFields := []string{"password", "token", "secret", "key", "authorization"}
|
||||
|
||||
for key, value := range data {
|
||||
keyLower := strings.ToLower(key)
|
||||
for _, sensitive := range sensitiveFields {
|
||||
if strings.Contains(keyLower, sensitive) {
|
||||
data[key] = "[REDACTED]"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively sanitize nested objects
|
||||
if nested, ok := value.(map[string]interface{}); ok {
|
||||
sanitizeJSON(nested)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getSessionID(c *gin.Context) string {
|
||||
// Try to get session ID from various sources
|
||||
if sessionID := c.GetHeader("X-Session-ID"); sessionID != "" {
|
||||
return sessionID
|
||||
}
|
||||
|
||||
// You could also get from JWT claims or cookie
|
||||
return ""
|
||||
}
|
||||
|
||||
func getCountryFromIP(ip string) string {
|
||||
// This is a placeholder - in production, you'd use a GeoIP service
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func getDeviceFromUserAgent(userAgent string) string {
|
||||
if strings.Contains(userAgent, "Mobile") {
|
||||
return "mobile"
|
||||
} else if strings.Contains(userAgent, "Tablet") {
|
||||
return "tablet"
|
||||
}
|
||||
return "desktop"
|
||||
}
|
||||
|
||||
func getPlatformFromUserAgent(userAgent string) string {
|
||||
if strings.Contains(userAgent, "Windows") {
|
||||
return "windows"
|
||||
} else if strings.Contains(userAgent, "Mac") {
|
||||
return "macos"
|
||||
} else if strings.Contains(userAgent, "Linux") {
|
||||
return "linux"
|
||||
} else if strings.Contains(userAgent, "Android") {
|
||||
return "android"
|
||||
} else if strings.Contains(userAgent, "iOS") {
|
||||
return "ios"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func getBrowserFromUserAgent(userAgent string) string {
|
||||
if strings.Contains(userAgent, "Chrome") {
|
||||
return "chrome"
|
||||
} else if strings.Contains(userAgent, "Firefox") {
|
||||
return "firefox"
|
||||
} else if strings.Contains(userAgent, "Safari") {
|
||||
return "safari"
|
||||
} else if strings.Contains(userAgent, "Edge") {
|
||||
return "edge"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func assessRisk(c *gin.Context, startTime time.Time) string {
|
||||
path := c.Request.URL.Path
|
||||
method := c.Request.Method
|
||||
status := c.Writer.Status()
|
||||
duration := time.Since(startTime)
|
||||
|
||||
// High risk indicators
|
||||
if strings.Contains(path, "/admin") {
|
||||
return "high"
|
||||
}
|
||||
if strings.Contains(path, "/auth") && status >= 400 {
|
||||
return "medium"
|
||||
}
|
||||
if method == "DELETE" {
|
||||
return "medium"
|
||||
}
|
||||
if duration > 5*time.Second {
|
||||
return "medium"
|
||||
}
|
||||
|
||||
return "low"
|
||||
}
|
||||
|
||||
func assessSecurityRisk(action, failureReason string) string {
|
||||
if failureReason != "" {
|
||||
return "high"
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "login_failed":
|
||||
return "medium"
|
||||
case "disable", "delete":
|
||||
return "high"
|
||||
default:
|
||||
return "low"
|
||||
}
|
||||
}
|
||||
|
||||
func assessActionRisk(action models.AuditAction, resource models.AuditResource) string {
|
||||
if action == models.AuditActionDelete {
|
||||
return "medium"
|
||||
}
|
||||
if resource == models.AuditResourceSecurity {
|
||||
return "medium"
|
||||
}
|
||||
return "low"
|
||||
}
|
||||
|
||||
func isSuspiciousActivity(action, failureReason string) bool {
|
||||
// Define suspicious activity patterns
|
||||
if action == "login_failed" && failureReason == "too_many_attempts" {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(failureReason, "suspicious") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getFailureReason(statusCode int) string {
|
||||
switch statusCode {
|
||||
case 400:
|
||||
return "bad_request"
|
||||
case 401:
|
||||
return "unauthorized"
|
||||
case 403:
|
||||
return "forbidden"
|
||||
case 404:
|
||||
return "not_found"
|
||||
case 429:
|
||||
return "rate_limited"
|
||||
case 500:
|
||||
return "server_error"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func saveAuditLog(auditLog *models.AuditLog) {
|
||||
// Skip audit logging in demo mode
|
||||
if os.Getenv("VITE_DEMO_MODE") == "true" {
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
if db != nil {
|
||||
db.Create(auditLog)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-redis/redis/v8"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
// CacheConfig holds cache configuration
|
||||
type CacheConfig struct {
|
||||
Duration time.Duration
|
||||
KeyPrefix string
|
||||
Enabled bool
|
||||
RedisClient *redis.Client
|
||||
}
|
||||
|
||||
// DefaultCacheConfig returns default cache configuration
|
||||
func DefaultCacheConfig() CacheConfig {
|
||||
return CacheConfig{
|
||||
Duration: 5 * time.Minute,
|
||||
KeyPrefix: "trackeep:",
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// CacheMiddleware creates a cache middleware
|
||||
func CacheMiddleware(config CacheConfig) gin.HandlerFunc {
|
||||
if !config.Enabled || config.RedisClient == nil {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
// Only cache GET requests
|
||||
if c.Request.Method != http.MethodGet {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Generate cache key
|
||||
cacheKey := generateCacheKey(c, config.KeyPrefix)
|
||||
|
||||
// Try to get from cache
|
||||
cached, err := config.RedisClient.Get(context.Background(), cacheKey).Result()
|
||||
if err == nil && cached != "" {
|
||||
// Cache hit
|
||||
c.Header("X-Cache", "HIT")
|
||||
c.Header("Content-Type", "application/json")
|
||||
c.String(http.StatusOK, cached)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Cache miss, continue with request
|
||||
c.Header("X-Cache", "MISS")
|
||||
|
||||
// Capture response
|
||||
writer := &cachedResponseWriter{
|
||||
ResponseWriter: c.Writer,
|
||||
buffer: make([]byte, 0),
|
||||
}
|
||||
c.Writer = writer
|
||||
|
||||
c.Next()
|
||||
|
||||
// Cache the response if successful
|
||||
if c.Writer.Status() == http.StatusOK && len(writer.buffer) > 0 {
|
||||
config.RedisClient.Set(
|
||||
context.Background(),
|
||||
cacheKey,
|
||||
string(writer.buffer),
|
||||
config.Duration,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// generateCacheKey creates a unique cache key for the request
|
||||
func generateCacheKey(c *gin.Context, prefix string) string {
|
||||
// Include path, query params, and user ID if available
|
||||
keyParts := []string{
|
||||
prefix,
|
||||
c.Request.URL.Path,
|
||||
c.Request.URL.RawQuery,
|
||||
}
|
||||
|
||||
// Add user ID for personalized caching
|
||||
if userID := c.GetString("userID"); userID != "" {
|
||||
keyParts = append(keyParts, "user:"+userID)
|
||||
}
|
||||
|
||||
// Create hash of the key to avoid long keys
|
||||
key := strings.Join(keyParts, ":")
|
||||
hash := md5.Sum([]byte(key))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
// cachedResponseWriter captures response data for caching
|
||||
type cachedResponseWriter struct {
|
||||
gin.ResponseWriter
|
||||
buffer []byte
|
||||
}
|
||||
|
||||
func (w *cachedResponseWriter) Write(data []byte) (int, error) {
|
||||
w.buffer = append(w.buffer, data...)
|
||||
return w.ResponseWriter.Write(data)
|
||||
}
|
||||
|
||||
// InvalidateCache invalidates cache entries matching a pattern
|
||||
func InvalidateCache(redisClient *redis.Client, pattern string) error {
|
||||
if redisClient == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys, err := redisClient.Keys(context.Background(), pattern).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(keys) > 0 {
|
||||
return redisClient.Del(context.Background(), keys...).Err()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CacheInvalidationMiddleware invalidates cache on write operations
|
||||
func CacheInvalidationMiddleware(redisClient *redis.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
|
||||
// Invalidate cache on successful write operations
|
||||
if c.Writer.Status() >= 200 && c.Writer.Status() < 300 &&
|
||||
(c.Request.Method == http.MethodPost ||
|
||||
c.Request.Method == http.MethodPut ||
|
||||
c.Request.Method == http.MethodDelete) {
|
||||
|
||||
// Invalidate user-specific cache
|
||||
if userID := c.GetString("userID"); userID != "" {
|
||||
pattern := fmt.Sprintf("trackeep:*user:%s*", userID)
|
||||
InvalidateCache(redisClient, pattern)
|
||||
}
|
||||
|
||||
// Invalidate general cache for the affected resource
|
||||
resourcePattern := fmt.Sprintf("trackeep:*%s*", c.Request.URL.Path)
|
||||
InvalidateCache(redisClient, resourcePattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// DemoModeMiddleware prevents write operations when in demo mode
|
||||
func DemoModeMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Check if demo mode is enabled
|
||||
if os.Getenv("VITE_DEMO_MODE") == "true" {
|
||||
// Allow GET requests (read operations)
|
||||
if c.Request.Method == "GET" || c.Request.Method == "OPTIONS" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Allow specific write operations in demo mode
|
||||
path := c.Request.URL.Path
|
||||
if (strings.Contains(path, "/learning-paths") && c.Request.Method == "POST") ||
|
||||
(strings.Contains(path, "/bookmarks/content") && c.Request.Method == "POST") ||
|
||||
(strings.Contains(path, "/bookmarks/metadata") && c.Request.Method == "POST") {
|
||||
// Set demo user for these operations
|
||||
c.Set("user", models.User{
|
||||
ID: 1,
|
||||
Username: "demo",
|
||||
Email: "[email protected]",
|
||||
})
|
||||
c.Set("user_id", uint(1))
|
||||
c.Set("userID", uint(1)) // Add this for compatibility with handlers
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Block other write operations (POST, PUT, DELETE, PATCH)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Write operations are disabled in demo mode",
|
||||
"message": "This is a demo instance. Database modifications are not allowed.",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// If not in demo mode, allow all operations
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// IsDemoMode returns true if demo mode is enabled
|
||||
func IsDemoMode() bool {
|
||||
return os.Getenv("VITE_DEMO_MODE") == "true"
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// InputValidationMiddleware provides comprehensive input validation
|
||||
func InputValidationMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Validate query parameters
|
||||
for key, values := range c.Request.URL.Query() {
|
||||
for i, value := range values {
|
||||
if containsMaliciousContent(value) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid input detected",
|
||||
"message": "Query parameter contains potentially malicious content",
|
||||
"parameter": key,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
// Sanitize query parameters
|
||||
values[i] = sanitizeInput(value)
|
||||
}
|
||||
}
|
||||
|
||||
// For POST/PUT requests, we'll validate the body in the handler
|
||||
// since we need to know the expected structure
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateRequestBody validates JSON request bodies against common attack patterns
|
||||
func ValidateRequestBody() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Method == "GET" || c.Request.Method == "DELETE" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Read and validate the body
|
||||
bodyBytes, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
bodyString := string(bodyBytes)
|
||||
|
||||
// Check for common injection patterns
|
||||
if containsMaliciousContent(bodyString) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid input detected",
|
||||
"message": "Request body contains potentially malicious content",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Restore the body for subsequent handlers
|
||||
c.Request.Body = &requestBody{body: bodyBytes}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// requestBody is a custom type to restore the request body
|
||||
type requestBody struct {
|
||||
body []byte
|
||||
pos int
|
||||
}
|
||||
|
||||
func (rb *requestBody) Read(p []byte) (int, error) {
|
||||
if rb.pos >= len(rb.body) {
|
||||
return 0, nil
|
||||
}
|
||||
n := copy(p, rb.body[rb.pos:])
|
||||
rb.pos += n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (rb *requestBody) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// containsMaliciousContent checks for common attack patterns
|
||||
func containsMaliciousContent(input string) bool {
|
||||
// Convert to lowercase for case-insensitive matching
|
||||
lowerInput := strings.ToLower(input)
|
||||
|
||||
// SQL injection patterns
|
||||
sqlPatterns := []string{
|
||||
"union select",
|
||||
"drop table",
|
||||
"insert into",
|
||||
"delete from",
|
||||
"update set",
|
||||
"exec(",
|
||||
"execute(",
|
||||
"sp_executesql",
|
||||
"xp_cmdshell",
|
||||
"'--",
|
||||
"/*",
|
||||
"*/",
|
||||
"char(",
|
||||
"ascii(",
|
||||
"concat(",
|
||||
"substring(",
|
||||
"waitfor delay",
|
||||
"benchmark(",
|
||||
"sleep(",
|
||||
"pg_sleep(",
|
||||
}
|
||||
|
||||
for _, pattern := range sqlPatterns {
|
||||
if strings.Contains(lowerInput, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// XSS patterns
|
||||
xssPatterns := []string{
|
||||
"<script",
|
||||
"</script>",
|
||||
"javascript:",
|
||||
"vbscript:",
|
||||
"onload=",
|
||||
"onerror=",
|
||||
"onclick=",
|
||||
"onmouseover=",
|
||||
"onfocus=",
|
||||
"onblur=",
|
||||
"onchange=",
|
||||
"onsubmit=",
|
||||
"<iframe",
|
||||
"<object",
|
||||
"<embed",
|
||||
"<form",
|
||||
"<input",
|
||||
"<link",
|
||||
"<meta",
|
||||
"<style",
|
||||
"eval(",
|
||||
"alert(",
|
||||
"confirm(",
|
||||
"prompt(",
|
||||
"document.cookie",
|
||||
"document.write",
|
||||
"window.location",
|
||||
}
|
||||
|
||||
for _, pattern := range xssPatterns {
|
||||
if strings.Contains(lowerInput, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Command injection patterns
|
||||
cmdPatterns := []string{
|
||||
"; rm",
|
||||
"; cat",
|
||||
"; ls",
|
||||
"; ps",
|
||||
"; kill",
|
||||
"; chmod",
|
||||
"; chown",
|
||||
"; wget",
|
||||
"; curl",
|
||||
"; nc",
|
||||
"; netcat",
|
||||
"| rm",
|
||||
"| cat",
|
||||
"| ls",
|
||||
"| ps",
|
||||
"& rm",
|
||||
"& cat",
|
||||
"& ls",
|
||||
"&& rm",
|
||||
"&& cat",
|
||||
"&& ls",
|
||||
"`rm",
|
||||
"`cat",
|
||||
"`ls",
|
||||
"$(rm",
|
||||
"$(cat",
|
||||
"$(ls",
|
||||
}
|
||||
|
||||
for _, pattern := range cmdPatterns {
|
||||
if strings.Contains(lowerInput, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Path traversal patterns
|
||||
pathPatterns := []string{
|
||||
"../",
|
||||
"..\\",
|
||||
"%2e%2e%2f",
|
||||
"%2e%2e\\",
|
||||
"..%2f",
|
||||
"..%5c",
|
||||
"%2e%2e/",
|
||||
"%2e%2e\\",
|
||||
"/etc/passwd",
|
||||
"/etc/shadow",
|
||||
"/etc/hosts",
|
||||
"windows/system32",
|
||||
"\\windows\\system32",
|
||||
}
|
||||
|
||||
for _, pattern := range pathPatterns {
|
||||
if strings.Contains(lowerInput, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// LDAP injection patterns
|
||||
ldapPatterns := []string{
|
||||
"*)(",
|
||||
"*}",
|
||||
"*)",
|
||||
"*(|",
|
||||
"*(|",
|
||||
"*)(",
|
||||
}
|
||||
|
||||
for _, pattern := range ldapPatterns {
|
||||
if strings.Contains(lowerInput, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// NoSQL injection patterns
|
||||
nosqlPatterns := []string{
|
||||
"$where",
|
||||
"$ne",
|
||||
"$gt",
|
||||
"$lt",
|
||||
"$regex",
|
||||
"$expr",
|
||||
"$json",
|
||||
"$or",
|
||||
"$and",
|
||||
"$not",
|
||||
}
|
||||
|
||||
for _, pattern := range nosqlPatterns {
|
||||
if strings.Contains(lowerInput, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// sanitizeInput cleans input by removing potentially dangerous characters
|
||||
func sanitizeInput(input string) string {
|
||||
// Remove null bytes
|
||||
input = strings.ReplaceAll(input, "\x00", "")
|
||||
|
||||
// Remove control characters except newline, tab, and carriage return
|
||||
var result []rune
|
||||
for _, r := range input {
|
||||
if unicode.IsControl(r) && r != '\n' && r != '\t' && r != '\r' {
|
||||
continue
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
|
||||
// Trim whitespace
|
||||
input = strings.TrimSpace(string(result))
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
// ValidateEmail validates email format
|
||||
func ValidateEmail(email string) bool {
|
||||
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||
return emailRegex.MatchString(email)
|
||||
}
|
||||
|
||||
// ValidatePassword validates password strength
|
||||
func ValidatePassword(password string) error {
|
||||
if len(password) < 8 {
|
||||
return gin.Error{
|
||||
Err: http.ErrBodyNotAllowed,
|
||||
Type: gin.ErrorTypeBind,
|
||||
Meta: "Password must be at least 8 characters long",
|
||||
}
|
||||
}
|
||||
|
||||
var hasUpper, hasLower, hasDigit, hasSpecial bool
|
||||
for _, char := range password {
|
||||
switch {
|
||||
case unicode.IsUpper(char):
|
||||
hasUpper = true
|
||||
case unicode.IsLower(char):
|
||||
hasLower = true
|
||||
case unicode.IsDigit(char):
|
||||
hasDigit = true
|
||||
case unicode.IsPunct(char) || unicode.IsSymbol(char):
|
||||
hasSpecial = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasUpper || !hasLower || !hasDigit || !hasSpecial {
|
||||
return gin.Error{
|
||||
Err: http.ErrBodyNotAllowed,
|
||||
Type: gin.ErrorTypeBind,
|
||||
Meta: "Password must contain at least one uppercase letter, one lowercase letter, one digit, and one special character",
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateUsername validates username format
|
||||
func ValidateUsername(username string) error {
|
||||
if len(username) < 3 || len(username) > 30 {
|
||||
return gin.Error{
|
||||
Err: http.ErrBodyNotAllowed,
|
||||
Type: gin.ErrorTypeBind,
|
||||
Meta: "Username must be between 3 and 30 characters long",
|
||||
}
|
||||
}
|
||||
|
||||
usernameRegex := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||
if !usernameRegex.MatchString(username) {
|
||||
return gin.Error{
|
||||
Err: http.ErrBodyNotAllowed,
|
||||
Type: gin.ErrorTypeBind,
|
||||
Meta: "Username can only contain letters, numbers, underscores, and hyphens",
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateID validates that ID is a positive integer
|
||||
func ValidateID(id string) error {
|
||||
idRegex := regexp.MustCompile(`^[1-9]\d*$`)
|
||||
if !idRegex.MatchString(id) {
|
||||
return gin.Error{
|
||||
Err: http.ErrBodyNotAllowed,
|
||||
Type: gin.ErrorTypeBind,
|
||||
Meta: "Invalid ID format",
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePagination validates pagination parameters
|
||||
func ValidatePagination(page, limit string) (int, int, error) {
|
||||
pageInt := 1
|
||||
limitInt := 20
|
||||
|
||||
if page != "" {
|
||||
if p, err := strconv.Atoi(page); err == nil && p > 0 {
|
||||
pageInt = p
|
||||
}
|
||||
}
|
||||
|
||||
if limit != "" {
|
||||
if l, err := strconv.Atoi(limit); err == nil && l > 0 && l <= 100 {
|
||||
limitInt = l
|
||||
}
|
||||
}
|
||||
|
||||
return pageInt, limitInt, nil
|
||||
}
|
||||
@@ -205,12 +205,6 @@ func logJSON(data map[string]interface{}) {
|
||||
log.Println(string(jsonData))
|
||||
}
|
||||
|
||||
// generateRequestID generates a unique request ID
|
||||
func generateRequestID() string {
|
||||
return time.Now().Format("20060102150405") + "-" +
|
||||
string(rune(time.Now().UnixNano()%1000))
|
||||
}
|
||||
|
||||
// SecurityLogger logs security-related events
|
||||
func SecurityLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// MemoryCacheItem represents an item in memory cache
|
||||
type MemoryCacheItem struct {
|
||||
Data []byte
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// MemoryCache represents an in-memory cache
|
||||
type MemoryCache struct {
|
||||
items map[string]MemoryCacheItem
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewMemoryCache creates a new memory cache
|
||||
func NewMemoryCache() *MemoryCache {
|
||||
cache := &MemoryCache{
|
||||
items: make(map[string]MemoryCacheItem),
|
||||
}
|
||||
|
||||
// Start cleanup goroutine
|
||||
go cache.cleanup()
|
||||
|
||||
return cache
|
||||
}
|
||||
|
||||
// Get retrieves an item from cache
|
||||
func (c *MemoryCache) Get(key string) ([]byte, bool) {
|
||||
c.mutex.RLock()
|
||||
defer c.mutex.RUnlock()
|
||||
|
||||
item, exists := c.items[key]
|
||||
if !exists || time.Now().After(item.ExpiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return item.Data, true
|
||||
}
|
||||
|
||||
// Set stores an item in cache
|
||||
func (c *MemoryCache) Set(key string, data []byte, duration time.Duration) {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
c.items[key] = MemoryCacheItem{
|
||||
Data: data,
|
||||
ExpiresAt: time.Now().Add(duration),
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removes an item from cache
|
||||
func (c *MemoryCache) Delete(key string) {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
delete(c.items, key)
|
||||
}
|
||||
|
||||
// DeletePattern removes items matching a pattern
|
||||
func (c *MemoryCache) DeletePattern(pattern string) {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
for key := range c.items {
|
||||
if strings.Contains(key, pattern) {
|
||||
delete(c.items, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanup removes expired items
|
||||
func (c *MemoryCache) cleanup() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
c.mutex.Lock()
|
||||
now := time.Now()
|
||||
for key, item := range c.items {
|
||||
if now.After(item.ExpiresAt) {
|
||||
delete(c.items, key)
|
||||
}
|
||||
}
|
||||
c.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Global memory cache instance
|
||||
var globalMemoryCache = NewMemoryCache()
|
||||
|
||||
// MemoryCacheConfig holds memory cache configuration
|
||||
type MemoryCacheConfig struct {
|
||||
Duration time.Duration
|
||||
KeyPrefix string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// DefaultMemoryCacheConfig returns default memory cache configuration
|
||||
func DefaultMemoryCacheConfig() MemoryCacheConfig {
|
||||
return MemoryCacheConfig{
|
||||
Duration: 5 * time.Minute,
|
||||
KeyPrefix: "trackeep:",
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// MemoryCacheMiddleware creates a memory cache middleware
|
||||
func MemoryCacheMiddleware(config MemoryCacheConfig) gin.HandlerFunc {
|
||||
if !config.Enabled {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
// Only cache GET requests
|
||||
if c.Request.Method != http.MethodGet {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Generate cache key
|
||||
cacheKey := generateMemoryCacheKey(c, config.KeyPrefix)
|
||||
|
||||
// Try to get from cache
|
||||
if data, hit := globalMemoryCache.Get(cacheKey); hit {
|
||||
// Cache hit
|
||||
c.Header("X-Cache", "HIT")
|
||||
c.Header("Content-Type", "application/json")
|
||||
c.Data(http.StatusOK, "application/json", data)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Cache miss, continue with request
|
||||
c.Header("X-Cache", "MISS")
|
||||
|
||||
// Capture response
|
||||
writer := &memoryCachedResponseWriter{
|
||||
ResponseWriter: c.Writer,
|
||||
buffer: make([]byte, 0),
|
||||
}
|
||||
c.Writer = writer
|
||||
|
||||
c.Next()
|
||||
|
||||
// Cache the response if successful
|
||||
if c.Writer.Status() == http.StatusOK && len(writer.buffer) > 0 {
|
||||
globalMemoryCache.Set(cacheKey, writer.buffer, config.Duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// generateMemoryCacheKey creates a unique cache key for the request
|
||||
func generateMemoryCacheKey(c *gin.Context, prefix string) string {
|
||||
// Include path, query params, and user ID if available
|
||||
keyParts := []string{
|
||||
prefix,
|
||||
c.Request.URL.Path,
|
||||
c.Request.URL.RawQuery,
|
||||
}
|
||||
|
||||
// Add user ID for personalized caching
|
||||
if userID := c.GetString("userID"); userID != "" {
|
||||
keyParts = append(keyParts, "user:"+userID)
|
||||
}
|
||||
|
||||
// Create hash of the key to avoid long keys
|
||||
key := strings.Join(keyParts, ":")
|
||||
hash := md5.Sum([]byte(key))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
// memoryCachedResponseWriter captures response data for caching
|
||||
type memoryCachedResponseWriter struct {
|
||||
gin.ResponseWriter
|
||||
buffer []byte
|
||||
}
|
||||
|
||||
func (w *memoryCachedResponseWriter) Write(data []byte) (int, error) {
|
||||
w.buffer = append(w.buffer, data...)
|
||||
return w.ResponseWriter.Write(data)
|
||||
}
|
||||
|
||||
// InvalidateMemoryCache invalidates cache entries matching a pattern
|
||||
func InvalidateMemoryCache(pattern string) {
|
||||
globalMemoryCache.DeletePattern(pattern)
|
||||
}
|
||||
|
||||
// MemoryCacheInvalidationMiddleware invalidates cache on write operations
|
||||
func MemoryCacheInvalidationMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
|
||||
// Invalidate cache on successful write operations
|
||||
if c.Writer.Status() >= 200 && c.Writer.Status() < 300 &&
|
||||
(c.Request.Method == http.MethodPost ||
|
||||
c.Request.Method == http.MethodPut ||
|
||||
c.Request.Method == http.MethodDelete) {
|
||||
|
||||
// Invalidate user-specific cache
|
||||
if userID := c.GetString("userID"); userID != "" {
|
||||
pattern := fmt.Sprintf("user:%s", userID)
|
||||
InvalidateMemoryCache(pattern)
|
||||
}
|
||||
|
||||
// Invalidate general cache for the affected resource
|
||||
resourcePattern := fmt.Sprintf("%s", c.Request.URL.Path)
|
||||
InvalidateMemoryCache(resourcePattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// PerformanceMiddleware adds performance monitoring to requests
|
||||
func PerformanceMiddleware() gin.HandlerFunc {
|
||||
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
||||
// Custom log format with performance metrics
|
||||
return fmt.Sprintf(
|
||||
"[%s] %s %s %d %s %s \"%s\" %s\n",
|
||||
param.TimeStamp.Format("02/Jan/2006:15:04:05 -0700"),
|
||||
param.Method,
|
||||
param.Path,
|
||||
param.StatusCode,
|
||||
param.Latency,
|
||||
param.Request.UserAgent(),
|
||||
param.ErrorMessage,
|
||||
param.Request.Header.Get("X-Request-ID"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// RequestIDMiddleware adds a unique request ID to each request
|
||||
func RequestIDMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestID := c.GetHeader("X-Request-ID")
|
||||
if requestID == "" {
|
||||
requestID = generateRequestID()
|
||||
}
|
||||
c.Set("RequestID", requestID)
|
||||
c.Header("X-Request-ID", requestID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// generateRequestID generates a unique request ID
|
||||
func generateRequestID() string {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// SlowQueryMiddleware logs slow database queries
|
||||
func SlowQueryMiddleware(threshold time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
duration := time.Since(start)
|
||||
|
||||
if duration > threshold {
|
||||
log.Printf("SLOW REQUEST: %s %s took %v", c.Request.Method, c.Request.URL.Path, duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RateLimiter implements a simple in-memory rate limiter
|
||||
type RateLimiter struct {
|
||||
clients map[string]*ClientInfo
|
||||
mutex sync.RWMutex
|
||||
limit int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
type ClientInfo struct {
|
||||
requests int
|
||||
resetTime time.Time
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a new rate limiter
|
||||
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
||||
limiter := &RateLimiter{
|
||||
clients: make(map[string]*ClientInfo),
|
||||
limit: limit,
|
||||
window: window,
|
||||
}
|
||||
|
||||
// Start cleanup goroutine
|
||||
go limiter.cleanup()
|
||||
|
||||
return limiter
|
||||
}
|
||||
|
||||
// Middleware returns the Gin middleware function
|
||||
func (rl *RateLimiter) Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
clientIP := c.ClientIP()
|
||||
|
||||
rl.mutex.Lock()
|
||||
client, exists := rl.clients[clientIP]
|
||||
|
||||
if !exists {
|
||||
client = &ClientInfo{
|
||||
requests: 0,
|
||||
resetTime: time.Now().Add(rl.window),
|
||||
}
|
||||
rl.clients[clientIP] = client
|
||||
}
|
||||
|
||||
// Reset window if expired
|
||||
if time.Now().After(client.resetTime) {
|
||||
client.requests = 0
|
||||
client.resetTime = time.Now().Add(rl.window)
|
||||
}
|
||||
|
||||
client.requests++
|
||||
|
||||
// Check if limit exceeded
|
||||
if client.requests > rl.limit {
|
||||
rl.mutex.Unlock()
|
||||
c.Header("X-RateLimit-Limit", fmt.Sprintf("%d", rl.limit))
|
||||
c.Header("X-RateLimit-Remaining", "0")
|
||||
c.Header("X-RateLimit-Reset", fmt.Sprintf("%d", client.resetTime.Unix()))
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "Rate limit exceeded",
|
||||
"message": fmt.Sprintf("Too many requests. Limit is %d per %v", rl.limit, rl.window),
|
||||
"retry_after": time.Until(client.resetTime).Seconds(),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
remaining := rl.limit - client.requests
|
||||
rl.mutex.Unlock()
|
||||
|
||||
// Set rate limit headers
|
||||
c.Header("X-RateLimit-Limit", fmt.Sprintf("%d", rl.limit))
|
||||
c.Header("X-RateLimit-Remaining", fmt.Sprintf("%d", remaining))
|
||||
c.Header("X-RateLimit-Reset", fmt.Sprintf("%d", client.resetTime.Unix()))
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// cleanup removes expired client entries
|
||||
func (rl *RateLimiter) cleanup() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
rl.mutex.Lock()
|
||||
now := time.Now()
|
||||
for ip, client := range rl.clients {
|
||||
if now.After(client.resetTime.Add(rl.window)) {
|
||||
delete(rl.clients, ip)
|
||||
}
|
||||
}
|
||||
rl.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimitConfig holds configuration for different endpoint types
|
||||
type RateLimitConfig struct {
|
||||
AuthRequests int // requests per window for auth endpoints
|
||||
GeneralRequests int // requests per window for general endpoints
|
||||
Window time.Duration // time window for rate limiting
|
||||
}
|
||||
|
||||
// DefaultRateLimitConfig returns sensible defaults
|
||||
func DefaultRateLimitConfig() RateLimitConfig {
|
||||
return RateLimitConfig{
|
||||
AuthRequests: 5, // 5 login attempts per minute
|
||||
GeneralRequests: 100, // 100 requests per minute
|
||||
Window: time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimit creates rate limiters for different endpoint types
|
||||
func RateLimit(config RateLimitConfig) map[string]*RateLimiter {
|
||||
return map[string]*RateLimiter{
|
||||
"auth": NewRateLimiter(config.AuthRequests, config.Window),
|
||||
"general": NewRateLimiter(config.GeneralRequests, config.Window),
|
||||
}
|
||||
}
|
||||
|
||||
// AuthRateLimit applies stricter rate limiting to authentication endpoints
|
||||
func AuthRateLimit(limiter *RateLimiter) gin.HandlerFunc {
|
||||
return limiter.Middleware()
|
||||
}
|
||||
|
||||
// GeneralRateLimit applies standard rate limiting to general endpoints
|
||||
func GeneralRateLimit(limiter *RateLimiter) gin.HandlerFunc {
|
||||
return limiter.Middleware()
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// SessionData represents the structure of session data stored in Redis
|
||||
type SessionData struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
SessionID string `json:"session_id"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastActive time.Time `json:"last_active"`
|
||||
}
|
||||
|
||||
// SessionStore interface for session storage
|
||||
type SessionStore interface {
|
||||
CreateSession(sessionData *SessionData) error
|
||||
GetSession(sessionID string) (*SessionData, error)
|
||||
UpdateSession(sessionID string, sessionData *SessionData) error
|
||||
DeleteSession(sessionID string) error
|
||||
CleanupExpiredSessions() error
|
||||
}
|
||||
|
||||
// RedisSessionStore implements SessionStore using Redis (or fallback to memory)
|
||||
type RedisSessionStore struct {
|
||||
sessions map[string]*SessionData // Fallback in-memory store
|
||||
}
|
||||
|
||||
// NewSessionStore creates a new session store
|
||||
func NewSessionStore() SessionStore {
|
||||
return &RedisSessionStore{
|
||||
sessions: make(map[string]*SessionData),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSession creates a new session
|
||||
func (r *RedisSessionStore) CreateSession(sessionData *SessionData) error {
|
||||
sessionData.CreatedAt = time.Now()
|
||||
sessionData.LastActive = time.Now()
|
||||
r.sessions[sessionData.SessionID] = sessionData
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSession retrieves a session by ID
|
||||
func (r *RedisSessionStore) GetSession(sessionID string) (*SessionData, error) {
|
||||
if session, exists := r.sessions[sessionID]; exists {
|
||||
// Update last active time
|
||||
session.LastActive = time.Now()
|
||||
return session, nil
|
||||
}
|
||||
return nil, fmt.Errorf("session not found")
|
||||
}
|
||||
|
||||
// UpdateSession updates an existing session
|
||||
func (r *RedisSessionStore) UpdateSession(sessionID string, sessionData *SessionData) error {
|
||||
if _, exists := r.sessions[sessionID]; exists {
|
||||
sessionData.LastActive = time.Now()
|
||||
r.sessions[sessionID] = sessionData
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("session not found")
|
||||
}
|
||||
|
||||
// DeleteSession removes a session
|
||||
func (r *RedisSessionStore) DeleteSession(sessionID string) error {
|
||||
delete(r.sessions, sessionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupExpiredSessions removes sessions older than 24 hours
|
||||
func (r *RedisSessionStore) CleanupExpiredSessions() error {
|
||||
now := time.Now()
|
||||
for sessionID, session := range r.sessions {
|
||||
if now.Sub(session.LastActive) > 24*time.Hour {
|
||||
delete(r.sessions, sessionID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Global session store instance
|
||||
var sessionStore SessionStore
|
||||
|
||||
// InitSessionStore initializes the session store
|
||||
func InitSessionStore() {
|
||||
sessionStore = NewSessionStore()
|
||||
|
||||
// Start cleanup goroutine
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
if sessionStore != nil {
|
||||
sessionStore.CleanupExpiredSessions()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// SessionMiddleware creates and manages user sessions
|
||||
func SessionMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Skip session management for health checks and static assets
|
||||
path := c.Request.URL.Path
|
||||
if path == "/health" || path == "/metrics" || strings.HasPrefix(path, "/static") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Get session ID from header or create new one
|
||||
sessionID := c.GetHeader("X-Session-ID")
|
||||
if sessionID == "" {
|
||||
sessionID = generateSessionID()
|
||||
c.Header("X-Session-ID", sessionID)
|
||||
}
|
||||
|
||||
// Try to get existing session
|
||||
session, err := sessionStore.GetSession(sessionID)
|
||||
if err != nil {
|
||||
// No existing session, check if user is authenticated via JWT
|
||||
if user, exists := c.Get("user"); exists {
|
||||
// Create session from authenticated user
|
||||
if userModel, ok := user.(models.User); ok {
|
||||
session = &SessionData{
|
||||
SessionID: sessionID,
|
||||
UserID: userModel.ID,
|
||||
Email: userModel.Email,
|
||||
Username: userModel.Username,
|
||||
Role: userModel.Role,
|
||||
IPAddress: c.ClientIP(),
|
||||
UserAgent: c.GetHeader("User-Agent"),
|
||||
}
|
||||
sessionStore.CreateSession(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set session data in context
|
||||
if session != nil {
|
||||
c.Set("session_id", session.SessionID)
|
||||
c.Set("session_user_id", session.UserID)
|
||||
c.Set("session_email", session.Email)
|
||||
c.Set("session_username", session.Username)
|
||||
c.Set("session_role", session.Role)
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetSessionFromContext retrieves session data from Gin context
|
||||
func GetSessionFromContext(c *gin.Context) (*SessionData, error) {
|
||||
if sessionID, exists := c.Get("session_id"); exists {
|
||||
return sessionStore.GetSession(sessionID.(string))
|
||||
}
|
||||
return nil, fmt.Errorf("no session in context")
|
||||
}
|
||||
|
||||
// GetUserIDFromSession safely gets user ID from session or context
|
||||
func GetUserIDFromSession(c *gin.Context) uint {
|
||||
// First try session
|
||||
if session, err := GetSessionFromContext(c); err == nil {
|
||||
return session.UserID
|
||||
}
|
||||
|
||||
// Fallback to context (for demo mode or JWT)
|
||||
if userID, exists := c.Get("user_id"); exists {
|
||||
if id, ok := userID.(uint); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
if userID, exists := c.Get("userID"); exists {
|
||||
if id, ok := userID.(uint); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback for demo mode
|
||||
if os.Getenv("VITE_DEMO_MODE") == "true" {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetUserEmailFromSession safely gets user email from session or context
|
||||
func GetUserEmailFromSession(c *gin.Context) string {
|
||||
// First try session
|
||||
if session, err := GetSessionFromContext(c); err == nil {
|
||||
return session.Email
|
||||
}
|
||||
|
||||
// Fallback to context
|
||||
if email, exists := c.Get("user_email"); exists {
|
||||
if e, ok := email.(string); ok {
|
||||
return e
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for demo mode
|
||||
if os.Getenv("VITE_DEMO_MODE") == "true" {
|
||||
return "[email protected]"
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// generateSessionID generates a unique session ID
|
||||
func generateSessionID() string {
|
||||
return fmt.Sprintf("sess_%d_%s", time.Now().UnixNano(), "trackeep")
|
||||
}
|
||||
|
||||
// GetSessionStore returns the global session store instance
|
||||
func GetSessionStore() SessionStore {
|
||||
return sessionStore
|
||||
}
|
||||
|
||||
// CleanupSessionsOnShutdown gracefully cleans up sessions
|
||||
func CleanupSessionsOnShutdown() {
|
||||
if sessionStore != nil {
|
||||
sessionStore.CleanupExpiredSessions()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AISummary represents an AI-generated summary
|
||||
type AISummary struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Source content
|
||||
ContentType string `json:"content_type" gorm:"not null"` // "bookmark", "note", "file"
|
||||
ContentID uint `json:"content_id" gorm:"not null"`
|
||||
|
||||
// Summary data
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary" gorm:"type:text"`
|
||||
KeyPoints string `json:"key_points" gorm:"type:text"` // JSON array of key points
|
||||
Tags string `json:"tags" gorm:"type:text"` // JSON array of suggested tags
|
||||
ReadTime int `json:"read_time"` // Estimated reading time in minutes
|
||||
Complexity string `json:"complexity" gorm:"default:'medium'"` // "low", "medium", "high"
|
||||
|
||||
// AI metadata
|
||||
ModelUsed string `json:"model_used"`
|
||||
ProcessingMs int64 `json:"processing_ms"`
|
||||
TokenCount int `json:"token_count"`
|
||||
Confidence float64 `json:"confidence"` // AI confidence score 0-1
|
||||
LastAnalyzed time.Time `json:"last_analyzed"`
|
||||
}
|
||||
|
||||
// AITaskSuggestion represents AI-generated task suggestions
|
||||
type AITaskSuggestion struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Suggestion data
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Priority string `json:"priority" gorm:"default:'medium'"` // "low", "medium", "high", "urgent"
|
||||
Category string `json:"category"` // "work", "personal", "learning", "health", etc.
|
||||
|
||||
// Context and reasoning
|
||||
Reasoning string `json:"reasoning" gorm:"type:text"` // Why this task is suggested
|
||||
ContextType string `json:"context_type"` // "calendar", "bookmarks", "deadlines", "habits"
|
||||
ContextData string `json:"context_data" gorm:"type:text"` // JSON data about context
|
||||
Deadline *time.Time `json:"deadline"`
|
||||
EstimatedTime int `json:"estimated_time"` // in minutes
|
||||
|
||||
// AI metadata
|
||||
ModelUsed string `json:"model_used"`
|
||||
Confidence float64 `json:"confidence"` // AI confidence score 0-1
|
||||
Accepted bool `json:"accepted" gorm:"default:false"`
|
||||
Dismissed bool `json:"dismissed" gorm:"default:false"`
|
||||
}
|
||||
|
||||
// AITagSuggestion represents AI-generated tag suggestions
|
||||
type AITagSuggestion struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Target content
|
||||
ContentType string `json:"content_type" gorm:"not null"` // "bookmark", "note", "task", "file"
|
||||
ContentID uint `json:"content_id" gorm:"not null"`
|
||||
|
||||
// Tag suggestions
|
||||
SuggestedTags string `json:"suggested_tags" gorm:"type:text"` // JSON array of suggested tags
|
||||
ExistingTags string `json:"existing_tags" gorm:"type:text"` // JSON array of current tags
|
||||
Relevance float64 `json:"relevance"` // Relevance score 0-1
|
||||
|
||||
// AI metadata
|
||||
ModelUsed string `json:"model_used"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Applied bool `json:"applied" gorm:"default:false"`
|
||||
Dismissed bool `json:"dismissed" gorm:"default:false"`
|
||||
}
|
||||
|
||||
// AIContentGeneration represents AI-generated content
|
||||
type AIContentGeneration struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Generation request
|
||||
Prompt string `json:"prompt" gorm:"type:text"`
|
||||
ContentType string `json:"content_type" gorm:"not null"` // "blog", "code", "email", "summary", "outline"
|
||||
Context string `json:"context" gorm:"type:text"` // Additional context for generation
|
||||
|
||||
// Generated content
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content" gorm:"type:text"`
|
||||
WordCount int `json:"word_count"`
|
||||
ReadTime int `json:"read_time"` // Estimated reading time in minutes
|
||||
|
||||
// AI metadata
|
||||
ModelUsed string `json:"model_used"`
|
||||
ProcessingMs int64 `json:"processing_ms"`
|
||||
TokenCount int `json:"token_count"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
Used bool `json:"used" gorm:"default:false"`
|
||||
Rating *int `json:"rating"` // User rating 1-5
|
||||
Feedback string `json:"feedback" gorm:"type:text"`
|
||||
}
|
||||
|
||||
// AICodeReview represents AI code review analysis
|
||||
type AICodeReview struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Code context
|
||||
RepositoryURL string `json:"repository_url"`
|
||||
CommitHash string `json:"commit_hash"`
|
||||
FilePath string `json:"file_path"`
|
||||
PRNumber int `json:"pr_number"`
|
||||
BranchName string `json:"branch_name"`
|
||||
|
||||
// Review data
|
||||
OriginalCode string `json:"original_code" gorm:"type:text"`
|
||||
Suggestions string `json:"suggestions" gorm:"type:text"` // JSON array of suggestions
|
||||
Issues string `json:"issues" gorm:"type:text"` // JSON array of issues found
|
||||
Score int `json:"score" gorm:"default:0"` // Code quality score 0-100
|
||||
SecurityIssues string `json:"security_issues" gorm:"type:text"` // JSON array of security issues
|
||||
Performance string `json:"performance" gorm:"type:text"` // Performance suggestions
|
||||
|
||||
// AI metadata
|
||||
ModelUsed string `json:"model_used"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
ProcessingMs int64 `json:"processing_ms"`
|
||||
TokenCount int `json:"token_count"`
|
||||
Applied bool `json:"applied" gorm:"default:false"`
|
||||
}
|
||||
|
||||
// AILearningRecommendation represents AI-generated learning recommendations
|
||||
type AILearningRecommendation struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Recommendation data
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Category string `json:"category"` // "programming", "design", "business", etc.
|
||||
Difficulty string `json:"difficulty"` // "beginner", "intermediate", "advanced"
|
||||
EstimatedHours int `json:"estimated_hours"` // Estimated time to complete
|
||||
Prerequisites string `json:"prerequisites" gorm:"type:text"` // JSON array of prerequisites
|
||||
Resources string `json:"resources" gorm:"type:text"` // JSON array of learning resources
|
||||
CourseID *uint `json:"course_id"` // Link to existing course if applicable
|
||||
|
||||
// Personalization
|
||||
Reasoning string `json:"reasoning" gorm:"type:text"` // Why this is recommended
|
||||
RelevanceScore float64 `json:"relevance_score"` // How relevant to user 0-1
|
||||
CareerImpact string `json:"career_impact"` // Career impact description
|
||||
SkillGained string `json:"skill_gained"` // Primary skill gained
|
||||
|
||||
// AI metadata
|
||||
ModelUsed string `json:"model_used"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Started bool `json:"started" gorm:"default:false"`
|
||||
Completed bool `json:"completed" gorm:"default:false"`
|
||||
Rating *int `json:"rating"` // User rating 1-5
|
||||
Feedback string `json:"feedback" gorm:"type:text"`
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AIRecommendation represents an AI-generated recommendation
|
||||
type AIRecommendation struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// User information
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Recommendation details
|
||||
RecommendationType string `json:"recommendation_type" gorm:"not null;index"` // content, task, learning, connection
|
||||
ContentType string `json:"content_type" gorm:"index"` // bookmark, note, task, course, user
|
||||
ContentID *uint `json:"content_id,omitempty" gorm:"index"`
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
Reasoning string `json:"reasoning"` // Why this was recommended
|
||||
|
||||
// Content details (for display without additional queries)
|
||||
ContentTitle string `json:"content_title"`
|
||||
ContentURL string `json:"content_url"`
|
||||
ContentPreview string `json:"content_preview"`
|
||||
AuthorName string `json:"author_name"`
|
||||
Tags string `json:"tags" gorm:"serializer:json"`
|
||||
|
||||
// Recommendation metadata
|
||||
Confidence float64 `json:"confidence" gorm:"default:0.0"` // 0.0 to 1.0
|
||||
Priority string `json:"priority" gorm:"default:medium"` // low, medium, high
|
||||
Category string `json:"category"` // productivity, learning, collaboration, etc.
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
Clicked bool `json:"clicked" gorm:"default:false"`
|
||||
Dismissed bool `json:"dismissed" gorm:"default:false"`
|
||||
ClickedAt *time.Time `json:"clicked_at"`
|
||||
DismissedAt *time.Time `json:"dismissed_at"`
|
||||
|
||||
// Feedback
|
||||
Feedback string `json:"feedback"` // helpful, not_helpful, irrelevant
|
||||
FeedbackAt *time.Time `json:"feedback_at"`
|
||||
FeedbackText string `json:"feedback_text"`
|
||||
|
||||
// Source information
|
||||
SourceModel string `json:"source_model"` // Which AI model generated this
|
||||
SourceVersion string `json:"source_version"` // Version of the recommendation engine
|
||||
TrainingData string `json:"training_data"` // What data was used for training
|
||||
}
|
||||
|
||||
// UserPreference represents user preferences for recommendations
|
||||
type UserPreference struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// User information
|
||||
UserID uint `json:"user_id" gorm:"not null;uniqueIndex"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Recommendation preferences
|
||||
EnableRecommendations bool `json:"enable_recommendations" gorm:"default:true"`
|
||||
ContentRecommendations bool `json:"content_recommendations" gorm:"default:true"`
|
||||
TaskRecommendations bool `json:"task_recommendations" gorm:"default:true"`
|
||||
LearningRecommendations bool `json:"learning_recommendations" gorm:"default:true"`
|
||||
ConnectionRecommendations bool `json:"connection_recommendations" gorm:"default:false"`
|
||||
|
||||
// Frequency and timing
|
||||
MaxRecommendationsPerDay int `json:"max_recommendations_per_day" gorm:"default:5"`
|
||||
PreferredCategories []string `json:"preferred_categories" gorm:"serializer:json"`
|
||||
BlockedCategories []string `json:"blocked_categories" gorm:"serializer:json"`
|
||||
PreferredContentTypes []string `json:"preferred_content_types" gorm:"serializer:json"`
|
||||
|
||||
// Quality thresholds
|
||||
MinConfidenceThreshold float64 `json:"min_confidence_threshold" gorm:"default:0.6"`
|
||||
MaxAgeHours int `json:"max_age_hours" gorm:"default:168"` // 1 week
|
||||
|
||||
// Learning and adaptation
|
||||
EnablePersonalization bool `json:"enable_personalization" gorm:"default:true"`
|
||||
EnableFeedbackLearning bool `json:"enable_feedback_learning" gorm:"default:true"`
|
||||
LastRecommendationAt *time.Time `json:"last_recommendation_at"`
|
||||
}
|
||||
|
||||
// RecommendationInteraction tracks user interactions with recommendations
|
||||
type RecommendationInteraction struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Related entities
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
RecommendationID uint `json:"recommendation_id" gorm:"not null;index"`
|
||||
Recommendation AIRecommendation `json:"recommendation,omitempty" gorm:"foreignKey:RecommendationID"`
|
||||
|
||||
// Interaction details
|
||||
InteractionType string `json:"interaction_type" gorm:"not null;index"` // view, click, dismiss, feedback, share
|
||||
InteractionData string `json:"interaction_data" gorm:"serializer:json"` // Additional context
|
||||
Duration int `json:"duration"` // Time spent in seconds (for views)
|
||||
Context string `json:"context"` // Where the interaction occurred (dashboard, search, etc.)
|
||||
|
||||
// Machine learning features
|
||||
UserActivityBefore string `json:"user_activity_before"` // What user was doing before
|
||||
UserActivityAfter string `json:"user_activity_after"` // What user did after
|
||||
SessionID string `json:"session_id"`
|
||||
DeviceType string `json:"device_type"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for AIRecommendation
|
||||
func (AIRecommendation) TableName() string {
|
||||
return "ai_recommendations"
|
||||
}
|
||||
|
||||
// TableName returns the table name for UserPreference
|
||||
func (UserPreference) TableName() string {
|
||||
return "user_preferences"
|
||||
}
|
||||
|
||||
// TableName returns the table name for RecommendationInteraction
|
||||
func (RecommendationInteraction) TableName() string {
|
||||
return "recommendation_interactions"
|
||||
}
|
||||
|
||||
// BeforeCreate hooks
|
||||
func (r *AIRecommendation) BeforeCreate(tx *gorm.DB) error {
|
||||
if r.Priority == "" {
|
||||
r.Priority = "medium"
|
||||
}
|
||||
if r.Confidence == 0 {
|
||||
r.Confidence = 0.5
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (up *UserPreference) BeforeCreate(tx *gorm.DB) error {
|
||||
if up.MaxRecommendationsPerDay == 0 {
|
||||
up.MaxRecommendationsPerDay = 5
|
||||
}
|
||||
if up.MinConfidenceThreshold == 0 {
|
||||
up.MinConfidenceThreshold = 0.6
|
||||
}
|
||||
if up.MaxAgeHours == 0 {
|
||||
up.MaxAgeHours = 168 // 1 week
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UserAISettings stores user-specific AI provider configurations
|
||||
type UserAISettings struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;uniqueIndex"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Mistral Settings
|
||||
MistralEnabled *bool `json:"mistral_enabled" gorm:"default:false"`
|
||||
MistralAPIKey string `json:"-" gorm:"column:mistral_api_key"` // Encrypted
|
||||
MistralModel string `json:"mistral_model" gorm:"default:mistral-small-latest"`
|
||||
MistralModelThinking string `json:"mistral_model_thinking" gorm:"default:mistral-large-latest"`
|
||||
|
||||
// Grok Settings
|
||||
GrokEnabled *bool `json:"grok_enabled" gorm:"default:false"`
|
||||
GrokAPIKey string `json:"-" gorm:"column:grok_api_key"` // Encrypted
|
||||
GrokBaseURL string `json:"grok_base_url" gorm:"default:https://api.x.ai/v1"`
|
||||
GrokModel string `json:"grok_model" gorm:"default:grok-4-1-fast-non-reasoning-latest"`
|
||||
GrokModelThinking string `json:"grok_model_thinking" gorm:"default:grok-4-1-fast-reasoning-latest"`
|
||||
|
||||
// DeepSeek Settings
|
||||
DeepSeekEnabled *bool `json:"deepseek_enabled" gorm:"default:false"`
|
||||
DeepSeekAPIKey string `json:"-" gorm:"column:deepseek_api_key"` // Encrypted
|
||||
DeepSeekBaseURL string `json:"deepseek_base_url" gorm:"default:https://api.deepseek.com"`
|
||||
DeepSeekModel string `json:"deepseek_model" gorm:"default:deepseek-chat"`
|
||||
DeepSeekModelThinking string `json:"deepseek_model_thinking" gorm:"default:deepseek-reasoner"`
|
||||
|
||||
// Ollama Settings
|
||||
OllamaEnabled *bool `json:"ollama_enabled" gorm:"default:false"`
|
||||
OllamaBaseURL string `json:"ollama_base_url" gorm:"default:http://localhost:11434"`
|
||||
OllamaModel string `json:"ollama_model" gorm:"default:llama3.1"`
|
||||
OllamaModelThinking string `json:"ollama_model_thinking" gorm:"default:llama3.1"`
|
||||
|
||||
// LongCat Settings
|
||||
LongCatEnabled *bool `json:"longcat_enabled" gorm:"default:false"`
|
||||
LongCatAPIKey string `json:"-" gorm:"column:longcat_api_key"` // Encrypted
|
||||
LongCatBaseURL string `json:"longcat_base_url" gorm:"default:https://api.longcat.chat"`
|
||||
LongCatOpenAIEndpoint string `json:"longcat_openai_endpoint" gorm:"default:https://api.longcat.chat/openai"`
|
||||
LongCatAnthropicEndpoint string `json:"longcat_anthropic_endpoint" gorm:"default:https://api.longcat.chat/anthropic"`
|
||||
LongCatModel string `json:"longcat_model" gorm:"default:LongCat-Flash-Chat"`
|
||||
LongCatModelThinking string `json:"longcat_model_thinking" gorm:"default:LongCat-Flash-Thinking"`
|
||||
LongCatModelThinkingUpgraded string `json:"longcat_model_thinking_upgraded" gorm:"default:LongCat-Flash-Thinking-2601"`
|
||||
LongCatFormat string `json:"longcat_format" gorm:"default:openai"`
|
||||
|
||||
// OpenRouter Settings
|
||||
OpenRouterEnabled *bool `json:"openrouter_enabled" gorm:"default:false"`
|
||||
OpenRouterAPIKey string `json:"-" gorm:"column:openrouter_api_key"` // Encrypted
|
||||
OpenRouterBaseURL string `json:"openrouter_base_url" gorm:"default:https://openrouter.ai/api"`
|
||||
OpenRouterModel string `json:"openrouter_model" gorm:"default:openrouter/auto"`
|
||||
OpenRouterModelThinking string `json:"openrouter_model_thinking" gorm:"default:openrouter/auto"`
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Analytics represents user analytics data
|
||||
type Analytics struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Analytics data
|
||||
Date time.Time `json:"date" gorm:"not null;index"`
|
||||
HoursTracked float64 `json:"hours_tracked"`
|
||||
TasksCompleted int `json:"tasks_completed"`
|
||||
BookmarksAdded int `json:"bookmarks_added"`
|
||||
NotesCreated int `json:"notes_created"`
|
||||
CoursesStarted int `json:"courses_started"`
|
||||
CoursesCompleted int `json:"courses_completed"`
|
||||
GitHubCommits int `json:"github_commits"`
|
||||
GitHubPRs int `json:"github_prs"`
|
||||
StudyStreak int `json:"study_streak"`
|
||||
ProductivityScore float64 `json:"productivity_score"`
|
||||
}
|
||||
|
||||
// ProductivityMetrics represents productivity analytics
|
||||
type ProductivityMetrics struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Productivity data
|
||||
Period string `json:"period"` // daily, weekly, monthly, yearly
|
||||
StartDate time.Time `json:"start_date"`
|
||||
EndDate time.Time `json:"end_date"`
|
||||
TotalHours float64 `json:"total_hours"`
|
||||
BillableHours float64 `json:"billable_hours"`
|
||||
NonBillableHours float64 `json:"non_billable_hours"`
|
||||
TasksCompleted int `json:"tasks_completed"`
|
||||
AverageTaskTime float64 `json:"average_task_time"`
|
||||
PeakProductivityHour int `json:"peak_productivity_hour"`
|
||||
FocusScore float64 `json:"focus_score"`
|
||||
EfficiencyScore float64 `json:"efficiency_score"`
|
||||
}
|
||||
|
||||
// LearningAnalytics represents learning progress analytics
|
||||
type LearningAnalytics struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Learning data
|
||||
CourseID uint `json:"course_id" gorm:"not null;index"`
|
||||
Course Course `json:"course,omitempty" gorm:"foreignKey:CourseID"`
|
||||
StartDate time.Time `json:"start_date"`
|
||||
LastAccessed time.Time `json:"last_accessed"`
|
||||
TimeSpent float64 `json:"time_spent"` // in hours
|
||||
Progress float64 `json:"progress"` // percentage 0-100
|
||||
ModulesCompleted int `json:"modules_completed"`
|
||||
TotalModules int `json:"total_modules"`
|
||||
QuizScores []float64 `json:"quiz_scores" gorm:"serializer:json"`
|
||||
AverageScore float64 `json:"average_score"`
|
||||
StreakDays int `json:"streak_days"`
|
||||
SkillsAcquired []string `json:"skills_acquired" gorm:"serializer:json"`
|
||||
CourseCompleted bool `json:"course_completed" gorm:"default:false"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
// ContentAnalytics represents content consumption patterns
|
||||
type ContentAnalytics struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Content data
|
||||
ContentType string `json:"content_type"` // bookmark, note, file, task
|
||||
ContentID uint `json:"content_id"`
|
||||
FirstAccessed time.Time `json:"first_accessed"`
|
||||
LastAccessed time.Time `json:"last_accessed"`
|
||||
AccessCount int `json:"access_count"`
|
||||
TimeSpent float64 `json:"time_spent"` // in minutes
|
||||
Tags []Tag `json:"tags,omitempty" gorm:"many2many:content_analytics_tags;"`
|
||||
Category string `json:"category"`
|
||||
Priority string `json:"priority"`
|
||||
UsefulnessScore float64 `json:"usefulness_score"` // user-rated 1-5
|
||||
}
|
||||
|
||||
// GitHubAnalytics represents GitHub contribution analytics
|
||||
type GitHubAnalytics struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// GitHub data
|
||||
Date time.Time `json:"date" gorm:"not null;index"`
|
||||
Commits int `json:"commits"`
|
||||
PullRequests int `json:"pull_requests"`
|
||||
IssuesOpened int `json:"issues_opened"`
|
||||
IssuesClosed int `json:"issues_closed"`
|
||||
Reviews int `json:"reviews"`
|
||||
Contributions int `json:"contributions"`
|
||||
Languages map[string]int `json:"languages" gorm:"serializer:json"`
|
||||
Repositories []string `json:"repositories" gorm:"serializer:json"`
|
||||
ActivityScore float64 `json:"activity_score"`
|
||||
}
|
||||
|
||||
// HabitAnalytics represents habit formation insights
|
||||
type HabitAnalytics struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Habit data
|
||||
HabitName string `json:"habit_name"`
|
||||
StartDate time.Time `json:"start_date"`
|
||||
LastCompleted time.Time `json:"last_completed"`
|
||||
Streak int `json:"streak"`
|
||||
BestStreak int `json:"best_streak"`
|
||||
TotalDays int `json:"total_days"`
|
||||
CompletionRate float64 `json:"completion_rate"`
|
||||
Frequency string `json:"frequency"` // daily, weekly, monthly
|
||||
Category string `json:"category"` // productivity, learning, health, etc.
|
||||
GoalTarget int `json:"goal_target"`
|
||||
GoalAchieved bool `json:"goal_achieved"`
|
||||
}
|
||||
|
||||
// Goal represents user goals for tracking
|
||||
type Goal struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Goal data
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"` // learning, productivity, health, career
|
||||
TargetValue float64 `json:"target_value"`
|
||||
CurrentValue float64 `json:"current_value"`
|
||||
Unit string `json:"unit"`
|
||||
Deadline time.Time `json:"deadline"`
|
||||
Status string `json:"status"` // active, completed, paused, cancelled
|
||||
Priority string `json:"priority"` // low, medium, high, urgent
|
||||
Progress float64 `json:"progress"` // percentage 0-100
|
||||
IsCompleted bool `json:"is_completed" gorm:"default:false"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
|
||||
// Relationships
|
||||
Milestones []Milestone `json:"milestones,omitempty" gorm:"foreignKey:GoalID"`
|
||||
}
|
||||
|
||||
// Milestone represents goal milestones
|
||||
type Milestone struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
GoalID uint `json:"goal_id" gorm:"not null;index"`
|
||||
Goal Goal `json:"goal,omitempty" gorm:"foreignKey:GoalID"`
|
||||
|
||||
// Milestone data
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
TargetValue float64 `json:"target_value"`
|
||||
CurrentValue float64 `json:"current_value"`
|
||||
Deadline time.Time `json:"deadline"`
|
||||
Status string `json:"status"` // pending, completed, overdue
|
||||
IsCompleted bool `json:"is_completed" gorm:"default:false"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
Order int `json:"order"`
|
||||
}
|
||||
|
||||
// AnalyticsReport represents a comprehensive analytics report
|
||||
type AnalyticsReport struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Report data
|
||||
ReportType string `json:"report_type"` // daily, weekly, monthly, yearly, custom
|
||||
StartDate time.Time `json:"start_date"`
|
||||
EndDate time.Time `json:"end_date"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Data map[string]interface{} `json:"data" gorm:"serializer:json"`
|
||||
Insights []string `json:"insights" gorm:"serializer:json"`
|
||||
Recommendations []string `json:"recommendations" gorm:"serializer:json"`
|
||||
ShareableLink string `json:"shareable_link"`
|
||||
IsPublic bool `json:"is_public" gorm:"default:false"`
|
||||
}
|
||||
|
||||
// BeforeCreate hooks for default values
|
||||
func (a *Analytics) BeforeCreate(tx *gorm.DB) error {
|
||||
if a.Date.IsZero() {
|
||||
a.Date = time.Now().Truncate(24 * time.Hour)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ProductivityMetrics) BeforeCreate(tx *gorm.DB) error {
|
||||
if p.StartDate.IsZero() {
|
||||
p.StartDate = time.Now().Truncate(24 * time.Hour)
|
||||
}
|
||||
if p.EndDate.IsZero() {
|
||||
p.EndDate = p.StartDate.Add(24 * time.Hour)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *LearningAnalytics) BeforeCreate(tx *gorm.DB) error {
|
||||
if l.StartDate.IsZero() {
|
||||
l.StartDate = time.Now()
|
||||
}
|
||||
if l.LastAccessed.IsZero() {
|
||||
l.LastAccessed = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ContentAnalytics) BeforeCreate(tx *gorm.DB) error {
|
||||
if c.FirstAccessed.IsZero() {
|
||||
c.FirstAccessed = time.Now()
|
||||
}
|
||||
if c.LastAccessed.IsZero() {
|
||||
c.LastAccessed = time.Now()
|
||||
}
|
||||
if c.AccessCount == 0 {
|
||||
c.AccessCount = 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *GitHubAnalytics) BeforeCreate(tx *gorm.DB) error {
|
||||
if g.Date.IsZero() {
|
||||
g.Date = time.Now().Truncate(24 * time.Hour)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *HabitAnalytics) BeforeCreate(tx *gorm.DB) error {
|
||||
if h.StartDate.IsZero() {
|
||||
h.StartDate = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Goal) BeforeCreate(tx *gorm.DB) error {
|
||||
if g.Status == "" {
|
||||
g.Status = "active"
|
||||
}
|
||||
if g.Priority == "" {
|
||||
g.Priority = "medium"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Milestone) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.Status == "" {
|
||||
m.Status = "pending"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AuditAction represents the type of action performed
|
||||
type AuditAction string
|
||||
|
||||
const (
|
||||
AuditActionCreate AuditAction = "create"
|
||||
AuditActionRead AuditAction = "read"
|
||||
AuditActionUpdate AuditAction = "update"
|
||||
AuditActionDelete AuditAction = "delete"
|
||||
AuditActionLogin AuditAction = "login"
|
||||
AuditActionLogout AuditAction = "logout"
|
||||
AuditActionLoginFail AuditAction = "login_failed"
|
||||
AuditActionExport AuditAction = "export"
|
||||
AuditActionImport AuditAction = "import"
|
||||
AuditActionEnable AuditAction = "enable"
|
||||
AuditActionDisable AuditAction = "disable"
|
||||
AuditActionUpload AuditAction = "upload"
|
||||
AuditActionDownload AuditAction = "download"
|
||||
AuditActionShare AuditAction = "share"
|
||||
AuditActionAccess AuditAction = "access"
|
||||
)
|
||||
|
||||
// AuditResource represents the resource type
|
||||
type AuditResource string
|
||||
|
||||
const (
|
||||
AuditResourceUser AuditResource = "user"
|
||||
AuditResourceNote AuditResource = "note"
|
||||
AuditResourceFile AuditResource = "file"
|
||||
AuditResourceBookmark AuditResource = "bookmark"
|
||||
AuditResourceTask AuditResource = "task"
|
||||
AuditResourceTimeEntry AuditResource = "time_entry"
|
||||
AuditResourceIntegration AuditResource = "integration"
|
||||
AuditResourceTeam AuditResource = "team"
|
||||
AuditResourceGoal AuditResource = "goal"
|
||||
AuditResourceHabit AuditResource = "habit"
|
||||
AuditResourceCalendar AuditResource = "calendar"
|
||||
AuditResourceSearch AuditResource = "search"
|
||||
AuditResourceAI AuditResource = "ai"
|
||||
AuditResourceAnalytics AuditResource = "analytics"
|
||||
AuditResourceSecurity AuditResource = "security"
|
||||
AuditResourceSystem AuditResource = "system"
|
||||
)
|
||||
|
||||
// AuditLog represents an audit log entry
|
||||
type AuditLog struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// User information
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
UserEmail string `json:"user_email" gorm:"not null"`
|
||||
UserIP string `json:"user_ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
|
||||
// Action information
|
||||
Action AuditAction `json:"action" gorm:"not null;index"`
|
||||
Resource AuditResource `json:"resource" gorm:"not null;index"`
|
||||
ResourceID *uint `json:"resource_id,omitempty" gorm:"index"`
|
||||
|
||||
// Details
|
||||
Description string `json:"description"`
|
||||
Details map[string]interface{} `json:"details" gorm:"serializer:json"`
|
||||
OldValues map[string]interface{} `json:"old_values" gorm:"serializer:json"`
|
||||
NewValues map[string]interface{} `json:"new_values" gorm:"serializer:json"`
|
||||
|
||||
// Security context
|
||||
SessionID string `json:"session_id"`
|
||||
Success bool `json:"success" gorm:"default:true"`
|
||||
FailureReason string `json:"failure_reason"`
|
||||
|
||||
// Geographic and device info
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
Device string `json:"device"`
|
||||
Platform string `json:"platform"`
|
||||
Browser string `json:"browser"`
|
||||
|
||||
// Risk assessment
|
||||
RiskLevel string `json:"risk_level" gorm:"default:low"` // low, medium, high, critical
|
||||
Suspicious bool `json:"suspicious" gorm:"default:false"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for AuditLog
|
||||
func (AuditLog) TableName() string {
|
||||
return "audit_logs"
|
||||
}
|
||||
|
||||
// BeforeCreate hook to set default values
|
||||
func (a *AuditLog) BeforeCreate(tx *gorm.DB) error {
|
||||
if a.RiskLevel == "" {
|
||||
a.RiskLevel = "low"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -19,21 +19,21 @@ type Bookmark struct {
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
URL string `json:"url" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
|
||||
|
||||
// Organization
|
||||
Tags []Tag `json:"tags,omitempty" gorm:"many2many:bookmark_tags;"`
|
||||
|
||||
Tags []Tag `json:"tags,omitempty" gorm:"many2many:bookmark_tags;"`
|
||||
|
||||
// Metadata
|
||||
Favicon string `json:"favicon"`
|
||||
Screenshot string `json:"screenshot"`
|
||||
IsRead bool `json:"is_read" gorm:"default:false"`
|
||||
IsFavorite bool `json:"is_favorite" gorm:"default:false"`
|
||||
|
||||
|
||||
// Content extraction
|
||||
Content string `json:"content"`
|
||||
Author string `json:"author"`
|
||||
Content string `json:"content"`
|
||||
Author string `json:"author"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
|
||||
|
||||
// Reading tracking
|
||||
ReadAt *time.Time `json:"read_at"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CalendarEvent represents an event in the calendar
|
||||
type CalendarEvent struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
|
||||
// Timing
|
||||
StartTime time.Time `json:"start_time" gorm:"not null"`
|
||||
EndTime time.Time `json:"end_time" gorm:"not null"`
|
||||
|
||||
// Classification
|
||||
Type string `json:"type" gorm:"default:'reminder'"` // task, meeting, deadline, reminder, habit
|
||||
Priority string `json:"priority" gorm:"default:'medium'"` // low, medium, high, urgent
|
||||
|
||||
// Location and attendees
|
||||
Location string `json:"location"`
|
||||
Attendees string `json:"attendees"` // JSON string of attendee emails/names
|
||||
|
||||
// Recurrence
|
||||
Recurring bool `json:"recurring" gorm:"default:false"`
|
||||
Rrule string `json:"rrule"` // RRULE format for recurrence
|
||||
|
||||
// Source integration
|
||||
Source string `json:"source" gorm:"default:'trackeep'"` // trackeep, google, outlook, manual
|
||||
|
||||
// Associations
|
||||
TaskID *uint `json:"task_id,omitempty"`
|
||||
Task *Task `json:"task,omitempty" gorm:"foreignKey:TaskID"`
|
||||
BookmarkID *uint `json:"bookmark_id,omitempty"`
|
||||
Bookmark *Bookmark `json:"bookmark,omitempty" gorm:"foreignKey:BookmarkID"`
|
||||
NoteID *uint `json:"note_id,omitempty"`
|
||||
Note *Note `json:"note,omitempty" gorm:"foreignKey:NoteID"`
|
||||
|
||||
// Status
|
||||
IsCompleted bool `json:"is_completed" gorm:"default:false"`
|
||||
IsAllDay bool `json:"is_all_day" gorm:"default:false"`
|
||||
|
||||
// Notifications
|
||||
ReminderMinutes int `json:"reminder_minutes"` // Minutes before event to remind
|
||||
}
|
||||
|
||||
// RecurrenceRule represents recurrence patterns for events
|
||||
type RecurrenceRule struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
Frequency string `json:"frequency" gorm:"not null"` // daily, weekly, monthly, yearly
|
||||
Interval int `json:"interval" gorm:"default:1"` // Every N days/weeks/months/years
|
||||
|
||||
// End conditions
|
||||
EndDate *time.Time `json:"end_date"`
|
||||
Count *int `json:"count"` // Number of occurrences
|
||||
IsForever bool `json:"is_forever" gorm:"default:false"`
|
||||
|
||||
// Weekly specifics
|
||||
DaysOfWeek string `json:"days_of_week"` // JSON array: [0,1,2,3,4,5,6] where 0=Sunday
|
||||
|
||||
// Monthly specifics
|
||||
DayOfMonth *int `json:"day_of_month"` // 1-31
|
||||
WeekOfMonth *int `json:"week_of_month"` // 1-5 (first to fifth week)
|
||||
DayOfWeek *int `json:"day_of_week"` // 0-6 (Sunday to Saturday)
|
||||
|
||||
// Event association
|
||||
EventID uint `json:"event_id" gorm:"not null"`
|
||||
Event CalendarEvent `json:"event,omitempty" gorm:"foreignKey:EventID"`
|
||||
}
|
||||
|
||||
// CalendarSettings represents user calendar preferences
|
||||
type CalendarSettings struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;uniqueIndex"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Display preferences
|
||||
DefaultView string `json:"default_view" gorm:"default:'week'"` // month, week, day
|
||||
WeekStartsOn int `json:"week_starts_on" gorm:"default:0"` // 0=Sunday, 1=Monday
|
||||
Timezone string `json:"timezone" gorm:"default:'UTC'"`
|
||||
TimeFormat24Hour bool `json:"time_format_24_hour" gorm:"default:true"`
|
||||
|
||||
// Integration settings
|
||||
GoogleCalendarEnabled bool `json:"google_calendar_enabled" gorm:"default:false"`
|
||||
GoogleCalendarToken string `json:"google_calendar_token"`
|
||||
OutlookEnabled bool `json:"outlook_enabled" gorm:"default:false"`
|
||||
OutlookToken string `json:"outlook_token"`
|
||||
|
||||
// Notification settings
|
||||
DefaultReminderMinutes int `json:"default_reminder_minutes" gorm:"default:15"`
|
||||
EmailRemindersEnabled bool `json:"email_reminders_enabled" gorm:"default:true"`
|
||||
PushRemindersEnabled bool `json:"push_reminders_enabled" gorm:"default:true"`
|
||||
}
|
||||
|
||||
// GetDuration returns the duration of the event
|
||||
func (e *CalendarEvent) GetDuration() time.Duration {
|
||||
return e.EndTime.Sub(e.StartTime)
|
||||
}
|
||||
|
||||
// IsOverdue checks if the event is overdue
|
||||
func (e *CalendarEvent) IsOverdue() bool {
|
||||
return !e.IsCompleted && time.Now().After(e.EndTime)
|
||||
}
|
||||
|
||||
// IsToday checks if the event occurs today
|
||||
func (e *CalendarEvent) IsToday() bool {
|
||||
now := time.Now()
|
||||
return e.StartTime.Year() == now.Year() &&
|
||||
e.StartTime.Month() == now.Month() &&
|
||||
e.StartTime.Day() == now.Day()
|
||||
}
|
||||
|
||||
// IsUpcoming checks if the event is in the next 7 days
|
||||
func (e *CalendarEvent) IsUpcoming() bool {
|
||||
now := time.Now()
|
||||
weekLater := now.AddDate(0, 0, 7)
|
||||
return e.StartTime.After(now) && e.StartTime.Before(weekLater)
|
||||
}
|
||||
|
||||
// GetPriorityColor returns a color based on priority
|
||||
func (e *CalendarEvent) GetPriorityColor() string {
|
||||
switch e.Priority {
|
||||
case "urgent":
|
||||
return "#ef4444" // red
|
||||
case "high":
|
||||
return "#f97316" // orange
|
||||
case "medium":
|
||||
return "#eab308" // yellow
|
||||
case "low":
|
||||
return "#22c55e" // green
|
||||
default:
|
||||
return "#6b7280" // gray
|
||||
}
|
||||
}
|
||||
|
||||
// GetTypeColor returns a color based on event type
|
||||
func (e *CalendarEvent) GetTypeColor() string {
|
||||
switch e.Type {
|
||||
case "task":
|
||||
return "#3b82f6" // blue
|
||||
case "meeting":
|
||||
return "#8b5cf6" // purple
|
||||
case "deadline":
|
||||
return "#ef4444" // red
|
||||
case "reminder":
|
||||
return "#06b6d4" // cyan
|
||||
case "habit":
|
||||
return "#10b981" // emerald
|
||||
default:
|
||||
return "#6b7280" // gray
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ChatMessage represents a chat message in the AI conversation
|
||||
type ChatMessage struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
Content string `json:"content" gorm:"not null"`
|
||||
Role string `json:"role" gorm:"not null"` // "user" or "assistant"
|
||||
|
||||
// Session tracking
|
||||
SessionID string `json:"session_id" gorm:"not null;index"`
|
||||
|
||||
// Metadata
|
||||
TokenCount int `json:"token_count"`
|
||||
ModelUsed string `json:"model_used"`
|
||||
ProcessingMs int64 `json:"processing_ms"`
|
||||
ContextItems []string `json:"context_items" gorm:"serializer:json"` // IDs of referenced items
|
||||
}
|
||||
|
||||
// ChatSession represents a chat session
|
||||
type ChatSession struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
Title string `json:"title"`
|
||||
|
||||
// Session metadata
|
||||
MessageCount int `json:"message_count" gorm:"default:0"`
|
||||
LastMessageAt *time.Time `json:"last_message_at"`
|
||||
|
||||
// Context configuration
|
||||
IncludeBookmarks bool `json:"include_bookmarks" gorm:"default:true"`
|
||||
IncludeTasks bool `json:"include_tasks" gorm:"default:true"`
|
||||
IncludeFiles bool `json:"include_files" gorm:"default:true"`
|
||||
IncludeNotes bool `json:"include_notes" gorm:"default:true"`
|
||||
|
||||
// Relationships
|
||||
Messages []ChatMessage `json:"messages,omitempty" gorm:"foreignKey:SessionID"`
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Challenge represents a community challenge
|
||||
type Challenge struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Creator information
|
||||
CreatorID uint `json:"creator_id" gorm:"not null;index"`
|
||||
Creator User `json:"creator,omitempty" gorm:"foreignKey:CreatorID"`
|
||||
|
||||
// Basic information
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Category string `json:"category" gorm:"not null"` // learning, productivity, fitness, creativity, technical
|
||||
|
||||
// Challenge details
|
||||
Difficulty string `json:"difficulty" gorm:"not null"` // beginner, intermediate, advanced, expert
|
||||
Duration int `json:"duration" gorm:"not null"` // Duration in days
|
||||
Requirements string `json:"requirements" gorm:"type:text"`
|
||||
Rewards string `json:"rewards" gorm:"type:text"`
|
||||
Rules string `json:"rules" gorm:"type:text"`
|
||||
|
||||
// Timeline
|
||||
StartDate time.Time `json:"start_date"`
|
||||
EndDate time.Time `json:"end_date"`
|
||||
|
||||
// Participation settings
|
||||
MaxParticipants *int `json:"max_participants,omitempty"` // nil for unlimited
|
||||
IsTeamChallenge bool `json:"is_team_challenge" gorm:"default:false"`
|
||||
TeamSize int `json:"team_size" gorm:"default:1"`
|
||||
|
||||
// Status and visibility
|
||||
Status string `json:"status" gorm:"default:draft"` // draft, active, completed, cancelled
|
||||
IsPublic bool `json:"is_public" gorm:"default:true"`
|
||||
IsFeatured bool `json:"is_featured" gorm:"default:false"`
|
||||
|
||||
// Tags and metadata
|
||||
Tags []ChallengeTag `json:"tags,omitempty" gorm:"many2many:challenge_tags;"`
|
||||
Image string `json:"image"` // Challenge banner/image
|
||||
Badge string `json:"badge"` // Completion badge
|
||||
|
||||
// Analytics
|
||||
ParticipantCount int `json:"participant_count" gorm:"default:0"`
|
||||
CompletionCount int `json:"completion_count" gorm:"default:0"`
|
||||
CompletionRate float64 `json:"completion_rate" gorm:"default:0"`
|
||||
LastActivityAt *time.Time `json:"last_activity_at,omitempty"`
|
||||
|
||||
// Relationships
|
||||
Participants []ChallengeParticipant `json:"participants,omitempty" gorm:"foreignKey:ChallengeID"`
|
||||
Milestones []ChallengeMilestone `json:"milestones,omitempty" gorm:"foreignKey:ChallengeID"`
|
||||
Resources []ChallengeResource `json:"resources,omitempty" gorm:"foreignKey:ChallengeID"`
|
||||
}
|
||||
|
||||
// ChallengeParticipant represents a user's participation in a challenge
|
||||
type ChallengeParticipant struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Participation information
|
||||
ChallengeID uint `json:"challenge_id" gorm:"not null;index"`
|
||||
Challenge Challenge `json:"challenge,omitempty" gorm:"foreignKey:ChallengeID"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Team information (for team challenges)
|
||||
TeamID *uint `json:"team_id,omitempty"`
|
||||
Team *ChallengeTeam `json:"team,omitempty" gorm:"foreignKey:TeamID"`
|
||||
Role string `json:"role" gorm:"default:participant"` // participant, team_leader
|
||||
|
||||
// Progress tracking
|
||||
Status string `json:"status" gorm:"default:joined"` // joined, in_progress, completed, dropped_out
|
||||
Progress float64 `json:"progress" gorm:"default:0"` // Progress percentage (0-100)
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
DroppedOutAt *time.Time `json:"dropped_out_at,omitempty"`
|
||||
|
||||
// Performance metrics
|
||||
Score int `json:"score" gorm:"default:0"`
|
||||
Rank int `json:"rank"`
|
||||
BadgeEarned bool `json:"badge_earned" gorm:"default:false"`
|
||||
LastActivityAt *time.Time `json:"last_activity_at,omitempty"`
|
||||
|
||||
// Notes and reflection
|
||||
Notes string `json:"notes" gorm:"type:text"`
|
||||
Reflection string `json:"reflection" gorm:"type:text"`
|
||||
}
|
||||
|
||||
// ChallengeTeam represents a team in a team challenge
|
||||
type ChallengeTeam struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Team information
|
||||
ChallengeID uint `json:"challenge_id" gorm:"not null;index"`
|
||||
Challenge Challenge `json:"challenge,omitempty" gorm:"foreignKey:ChallengeID"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Avatar string `json:"avatar"`
|
||||
|
||||
// Team settings
|
||||
IsPrivate bool `json:"is_private" gorm:"default:false"`
|
||||
MaxMembers int `json:"max_members" gorm:"default:5"`
|
||||
|
||||
// Team progress
|
||||
Status string `json:"status" gorm:"default:active"` // active, completed, disbanded
|
||||
Progress float64 `json:"progress" gorm:"default:0"`
|
||||
Score int `json:"score" gorm:"default:0"`
|
||||
Rank int `json:"rank"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
LastActivityAt *time.Time `json:"last_activity_at,omitempty"`
|
||||
|
||||
// Relationships
|
||||
LeaderID uint `json:"leader_id" gorm:"not null"`
|
||||
Leader User `json:"leader,omitempty" gorm:"foreignKey:LeaderID"`
|
||||
Members []ChallengeParticipant `json:"members,omitempty" gorm:"foreignKey:TeamID"`
|
||||
}
|
||||
|
||||
// ChallengeMilestone represents a milestone in a challenge
|
||||
type ChallengeMilestone struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Milestone information
|
||||
ChallengeID uint `json:"challenge_id" gorm:"not null;index"`
|
||||
Challenge Challenge `json:"challenge,omitempty" gorm:"foreignKey:ChallengeID"`
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
|
||||
// Milestone details
|
||||
Order int `json:"order" gorm:"not null"` // Order in the challenge
|
||||
TargetValue float64 `json:"target_value" gorm:"not null"`
|
||||
Unit string `json:"unit"` // days, points, hours, etc.
|
||||
Deadline time.Time `json:"deadline"`
|
||||
PointsAwarded int `json:"points_awarded" gorm:"default:0"`
|
||||
|
||||
// Status
|
||||
IsActive bool `json:"is_active" gorm:"default:true"`
|
||||
|
||||
// Relationships
|
||||
Completions []ChallengeMilestoneCompletion `json:"completions,omitempty" gorm:"foreignKey:MilestoneID"`
|
||||
}
|
||||
|
||||
// ChallengeMilestoneCompletion represents a user's completion of a milestone
|
||||
type ChallengeMilestoneCompletion struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Completion information
|
||||
MilestoneID uint `json:"milestone_id" gorm:"not null;index"`
|
||||
Milestone ChallengeMilestone `json:"milestone,omitempty" gorm:"foreignKey:MilestoneID"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
TeamID *uint `json:"team_id,omitempty"`
|
||||
|
||||
// Completion details
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
Notes string `json:"notes" gorm:"type:text"`
|
||||
Evidence string `json:"evidence" gorm:"type:text"` // Proof of completion
|
||||
PointsEarned int `json:"points_earned"`
|
||||
}
|
||||
|
||||
// ChallengeResource represents a resource for a challenge
|
||||
type ChallengeResource struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Resource information
|
||||
ChallengeID uint `json:"challenge_id" gorm:"not null;index"`
|
||||
Challenge Challenge `json:"challenge,omitempty" gorm:"foreignKey:ChallengeID"`
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type" gorm:"not null"` // article, video, tool, template, guide
|
||||
|
||||
// Resource details
|
||||
Order int `json:"order" gorm:"default:0"`
|
||||
IsRequired bool `json:"is_required" gorm:"default:false"`
|
||||
Duration int `json:"duration"` // Estimated duration in minutes
|
||||
Tags string `json:"tags"` // Comma-separated tags
|
||||
}
|
||||
|
||||
// ChallengeTag represents tags for challenges
|
||||
type ChallengeTag struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
Name string `json:"name" gorm:"uniqueIndex;not null"`
|
||||
Description string `json:"description"`
|
||||
Color string `json:"color" gorm:"default:#10b981"` // Tag color
|
||||
UsageCount int `json:"usage_count" gorm:"default:0"`
|
||||
}
|
||||
|
||||
// Mentorship represents a mentorship relationship
|
||||
type Mentorship struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Participants
|
||||
MentorID uint `json:"mentor_id" gorm:"not null;index"`
|
||||
Mentor User `json:"mentor,omitempty" gorm:"foreignKey:MentorID"`
|
||||
MenteeID uint `json:"mentee_id" gorm:"not null;index"`
|
||||
Mentee User `json:"mentee,omitempty" gorm:"foreignKey:MenteeID"`
|
||||
|
||||
// Mentorship details
|
||||
Category string `json:"category" gorm:"not null"` // career, technical, business, personal
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Goals string `json:"goals" gorm:"type:text"`
|
||||
|
||||
// Timeline
|
||||
StartDate time.Time `json:"start_date"`
|
||||
EndDate *time.Time `json:"end_date,omitempty"`
|
||||
|
||||
// Status and settings
|
||||
Status string `json:"status" gorm:"default:pending"` // pending, active, paused, completed, terminated
|
||||
IsPaid bool `json:"is_paid" gorm:"default:false"`
|
||||
Rate float64 `json:"rate" gorm:"default:0"` // Hourly rate or monthly rate
|
||||
Currency string `json:"currency" gorm:"default:USD"`
|
||||
SessionLimit int `json:"session_limit" gorm:"default:0"` // 0 for unlimited
|
||||
|
||||
// Matching preferences
|
||||
MentorPreferences string `json:"mentor_preferences" gorm:"type:text"`
|
||||
MenteePreferences string `json:"mentee_preferences" gorm:"type:text"`
|
||||
|
||||
// Analytics
|
||||
SessionCount int `json:"session_count" gorm:"default:0"`
|
||||
TotalHours float64 `json:"total_hours" gorm:"default:0"`
|
||||
LastSessionAt *time.Time `json:"last_session_at,omitempty"`
|
||||
SatisfactionScore float64 `json:"satisfaction_score" gorm:"default:0"` // 1-5 rating
|
||||
|
||||
// Relationships
|
||||
Sessions []MentorshipSession `json:"sessions,omitempty" gorm:"foreignKey:MentorshipID"`
|
||||
Reviews []MentorshipReview `json:"reviews,omitempty" gorm:"foreignKey:MentorshipID"`
|
||||
Milestones []MentorshipMilestone `json:"milestones,omitempty" gorm:"foreignKey:MentorshipID"`
|
||||
}
|
||||
|
||||
// MentorshipSession represents a mentoring session
|
||||
type MentorshipSession struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Session information
|
||||
MentorshipID uint `json:"mentorship_id" gorm:"not null;index"`
|
||||
Mentorship Mentorship `json:"mentorship,omitempty" gorm:"foreignKey:MentorshipID"`
|
||||
ScheduledFor time.Time `json:"scheduled_for"`
|
||||
Duration int `json:"duration"` // Duration in minutes
|
||||
Status string `json:"status" gorm:"default:scheduled"` // scheduled, completed, cancelled, no_show
|
||||
|
||||
// Session details
|
||||
Title string `json:"title"`
|
||||
Agenda string `json:"agenda" gorm:"type:text"`
|
||||
Notes string `json:"notes" gorm:"type:text"`
|
||||
RecordingURL string `json:"recording_url"`
|
||||
Materials string `json:"materials" gorm:"type:text"`
|
||||
|
||||
// Completion details
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
MentorNotes string `json:"mentor_notes" gorm:"type:text"`
|
||||
MenteeNotes string `json:"mentee_notes" gorm:"type:text"`
|
||||
ActionItems string `json:"action_items" gorm:"type:text"`
|
||||
NextSteps string `json:"next_steps" gorm:"type:text"`
|
||||
|
||||
// Feedback
|
||||
MentorRating *int `json:"mentor_rating,omitempty"` // 1-5 rating from mentee
|
||||
MenteeRating *int `json:"mentee_rating,omitempty"` // 1-5 rating from mentor
|
||||
MentorFeedback string `json:"mentor_feedback" gorm:"type:text"`
|
||||
MenteeFeedback string `json:"mentee_feedback" gorm:"type:text"`
|
||||
}
|
||||
|
||||
// MentorshipReview represents a review for a mentorship
|
||||
type MentorshipReview struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Review information
|
||||
MentorshipID uint `json:"mentorship_id" gorm:"not null;index"`
|
||||
Mentorship Mentorship `json:"mentorship,omitempty" gorm:"foreignKey:MentorshipID"`
|
||||
ReviewerID uint `json:"reviewer_id" gorm:"not null;index"`
|
||||
Reviewer User `json:"reviewer,omitempty" gorm:"foreignKey:ReviewerID"`
|
||||
TargetID uint `json:"target_id" gorm:"not null;index"` // The person being reviewed
|
||||
Target User `json:"target,omitempty" gorm:"foreignKey:TargetID"`
|
||||
|
||||
// Review content
|
||||
Rating int `json:"rating" gorm:"not null;check:rating >= 1 AND rating <= 5"` // 1-5 stars
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content" gorm:"type:text"`
|
||||
IsPublic bool `json:"is_public" gorm:"default:false"`
|
||||
IsVerified bool `json:"is_verified" gorm:"default:false"` // Verified mentorship
|
||||
|
||||
// Review metadata
|
||||
HelpfulCount int `json:"helpful_count" gorm:"default:0"`
|
||||
ReviewType string `json:"review_type" gorm:"not null"` // mentor_review, mentee_review
|
||||
}
|
||||
|
||||
// MentorshipMilestone represents a milestone in a mentorship
|
||||
type MentorshipMilestone struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Milestone information
|
||||
MentorshipID uint `json:"mentorship_id" gorm:"not null;index"`
|
||||
Mentorship Mentorship `json:"mentorship,omitempty" gorm:"foreignKey:MentorshipID"`
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
|
||||
// Milestone details
|
||||
TargetDate time.Time `json:"target_date"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
Status string `json:"status" gorm:"default:pending"` // pending, completed, overdue
|
||||
Priority string `json:"priority" gorm:"default:medium"` // low, medium, high
|
||||
|
||||
// Progress tracking
|
||||
Progress float64 `json:"progress" gorm:"default:0"` // 0-100
|
||||
Evidence string `json:"evidence" gorm:"type:text"`
|
||||
Notes string `json:"notes" gorm:"type:text"`
|
||||
}
|
||||
|
||||
// MentorshipRequest represents a mentorship request
|
||||
type MentorshipRequest struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Request information
|
||||
FromUserID uint `json:"from_user_id" gorm:"not null;index"`
|
||||
FromUser User `json:"from_user,omitempty" gorm:"foreignKey:FromUserID"`
|
||||
ToUserID uint `json:"to_user_id" gorm:"not null;index"`
|
||||
ToUser User `json:"to_user,omitempty" gorm:"foreignKey:ToUserID"`
|
||||
|
||||
// Request details
|
||||
Role string `json:"role" gorm:"not null"` // mentor, mentee
|
||||
Category string `json:"category" gorm:"not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Goals string `json:"goals" gorm:"type:text"`
|
||||
Availability string `json:"availability" gorm:"type:text"`
|
||||
Duration int `json:"duration"` // Desired duration in months
|
||||
IsPaid bool `json:"is_paid"`
|
||||
Rate float64 `json:"rate"`
|
||||
Currency string `json:"currency" gorm:"default:USD"`
|
||||
|
||||
// Status
|
||||
Status string `json:"status" gorm:"default:pending"` // pending, accepted, rejected, withdrawn
|
||||
RespondedAt *time.Time `json:"responded_at,omitempty"`
|
||||
Response string `json:"response" gorm:"type:text"`
|
||||
|
||||
// Matching score (calculated by matching algorithm)
|
||||
MatchScore float64 `json:"match_score" gorm:"default:0"`
|
||||
MatchReasons string `json:"match_reasons" gorm:"type:text"`
|
||||
}
|
||||
|
||||
// BeforeCreate hooks
|
||||
func (c *Challenge) BeforeCreate(tx *gorm.DB) error {
|
||||
if c.Status == "" {
|
||||
c.Status = "draft"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cp *ChallengeParticipant) BeforeCreate(tx *gorm.DB) error {
|
||||
if cp.Status == "" {
|
||||
cp.Status = "joined"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ct *ChallengeTeam) BeforeCreate(tx *gorm.DB) error {
|
||||
if ct.Status == "" {
|
||||
ct.Status = "active"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mentorship) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.Status == "" {
|
||||
m.Status = "pending"
|
||||
}
|
||||
if m.Currency == "" {
|
||||
m.Currency = "USD"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ms *MentorshipSession) BeforeCreate(tx *gorm.DB) error {
|
||||
if ms.Status == "" {
|
||||
ms.Status = "scheduled"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mr *MentorshipReview) BeforeCreate(tx *gorm.DB) error {
|
||||
if mr.ReviewType == "" {
|
||||
mr.ReviewType = "mentor_review"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mm *MentorshipMilestone) BeforeCreate(tx *gorm.DB) error {
|
||||
if mm.Status == "" {
|
||||
mm.Status = "pending"
|
||||
}
|
||||
if mm.Priority == "" {
|
||||
mm.Priority = "medium"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mr *MentorshipRequest) BeforeCreate(tx *gorm.DB) error {
|
||||
if mr.Status == "" {
|
||||
mr.Status = "pending"
|
||||
}
|
||||
if mr.Currency == "" {
|
||||
mr.Currency = "USD"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Course represents a Zero to Mastery course
|
||||
type Course struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Basic course information
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Slug string `json:"slug" gorm:"uniqueIndex;not null"` // URL-friendly course identifier
|
||||
URL string `json:"url" gorm:"not null"` // ZTM course URL
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Instructor string `json:"instructor"`
|
||||
|
||||
// Course metadata
|
||||
Duration string `json:"duration"` // e.g., "32 Hours"
|
||||
LessonsCount int `json:"lessons_count"` // number of lessons
|
||||
ModuleCount int `json:"module_count"` // number of modules
|
||||
Level string `json:"level"` // beginner, intermediate, advanced
|
||||
Category string `json:"category"` // programming, design, cybersecurity, etc.
|
||||
Price float64 `json:"price"` // course price
|
||||
Rating float64 `json:"rating"` // average rating
|
||||
StudentsCount int `json:"students_count"` // number of enrolled students
|
||||
|
||||
// Course content
|
||||
Prerequisites []string `json:"prerequisites" gorm:"serializer:json"`
|
||||
WhatYouLearn []string `json:"what_you_learn" gorm:"serializer:json"`
|
||||
Topics []string `json:"topics" gorm:"serializer:json"`
|
||||
ToolsAndTech []string `json:"tools_and_tech" gorm:"serializer:json"`
|
||||
|
||||
// ZTM specific data
|
||||
ZTMCourseID string `json:"ztm_course_id"` // internal ZTM course ID
|
||||
ZTMCategory string `json:"ztm_category"` // ZTM category classification
|
||||
IsZTMCourse bool `json:"is_ztm_course" gorm:"default:true"`
|
||||
LastUpdatedZTM *time.Time `json:"last_updated_ztm"` // last sync with ZTM
|
||||
|
||||
// Status
|
||||
IsActive bool `json:"is_active" gorm:"default:true"`
|
||||
IsFeatured bool `json:"is_featured" gorm:"default:false"`
|
||||
}
|
||||
|
||||
// LearningPathCourse represents the relationship between learning paths and courses
|
||||
type LearningPathCourse struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
LearningPathID uint `json:"learning_path_id" gorm:"not null;index"`
|
||||
LearningPath LearningPath `json:"learning_path,omitempty" gorm:"foreignKey:LearningPathID"`
|
||||
|
||||
CourseID uint `json:"course_id" gorm:"not null;index"`
|
||||
Course Course `json:"course,omitempty" gorm:"foreignKey:CourseID"`
|
||||
|
||||
// Relationship metadata
|
||||
Order int `json:"order" gorm:"not null"` // order in the learning path
|
||||
IsRequired bool `json:"is_required" gorm:"default:true"` // whether this course is required
|
||||
Notes string `json:"notes" gorm:"type:text"` // additional notes about why this course is included
|
||||
EstimatedWeeks int `json:"estimated_weeks"` // estimated weeks to complete this course
|
||||
}
|
||||
+15
-11
@@ -28,24 +28,28 @@ type File struct {
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
OriginalName string `json:"original_name" gorm:"not null"`
|
||||
FileName string `json:"file_name" gorm:"not null;uniqueIndex"`
|
||||
FilePath string `json:"file_path" gorm:"not null"`
|
||||
FileSize int64 `json:"file_size" gorm:"not null"`
|
||||
MimeType string `json:"mime_type" gorm:"not null"`
|
||||
FileType FileType `json:"file_type" gorm:"not null"`
|
||||
|
||||
OriginalName string `json:"original_name" gorm:"not null"`
|
||||
FileName string `json:"file_name" gorm:"not null;uniqueIndex"`
|
||||
FilePath string `json:"file_path" gorm:"not null"`
|
||||
FileSize int64 `json:"file_size" gorm:"not null"`
|
||||
MimeType string `json:"mime_type" gorm:"not null"`
|
||||
FileType FileType `json:"file_type" gorm:"not null"`
|
||||
|
||||
// Encryption
|
||||
IsEncrypted bool `json:"is_encrypted" gorm:"default:false"`
|
||||
EncryptionKey string `json:"-" gorm:"column:encryption_key"` // User-specific encryption key (optional)
|
||||
|
||||
// Organization
|
||||
Tags []Tag `json:"tags,omitempty" gorm:"many2many:file_tags;"`
|
||||
|
||||
|
||||
// Metadata
|
||||
Description string `json:"description"`
|
||||
IsPublic bool `json:"is_public" gorm:"default:false"`
|
||||
|
||||
IsPublic bool `json:"is_public" gorm:"default:false"`
|
||||
|
||||
// Preview/Thumbnail
|
||||
ThumbnailPath string `json:"thumbnail_path"`
|
||||
PreviewPath string `json:"preview_path"`
|
||||
|
||||
|
||||
// Content extraction (for documents)
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// FileAnalysis represents analysis results for a file
|
||||
type FileAnalysis struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// File information
|
||||
FileID uint `json:"file_id" gorm:"not null;uniqueIndex:idx_file_analysis_type"`
|
||||
File File `json:"file,omitempty" gorm:"foreignKey:FileID"`
|
||||
AnalysisType string `json:"analysis_type" gorm:"not null;uniqueIndex:idx_file_analysis_type"` // computer_vision, nlp, metadata
|
||||
|
||||
// Analysis results
|
||||
Results string `json:"results" gorm:"type:text"` // JSON-encoded analysis results
|
||||
Confidence float64 `json:"confidence" gorm:"default:0.0"` // 0.0 to 1.0
|
||||
Status string `json:"status" gorm:"default:pending"` // pending, processing, completed, failed
|
||||
|
||||
// Processing metadata
|
||||
ProcessedAt *time.Time `json:"processed_at"`
|
||||
ProcessingTime int `json:"processing_time"` // in milliseconds
|
||||
ModelVersion string `json:"model_version"` // AI model version used
|
||||
Error string `json:"error"` // Error message if failed
|
||||
|
||||
// Additional metadata
|
||||
Tags string `json:"tags" gorm:"serializer:json"`
|
||||
Metadata string `json:"metadata" gorm:"serializer:json"`
|
||||
ExtractedData string `json:"extracted_data" gorm:"type:text"` // Extracted text, objects, etc.
|
||||
}
|
||||
|
||||
// TableName returns the table name for FileAnalysis
|
||||
func (FileAnalysis) TableName() string {
|
||||
return "file_analyses"
|
||||
}
|
||||
|
||||
// BeforeCreate hook to set default values
|
||||
func (fa *FileAnalysis) BeforeCreate(tx *gorm.DB) error {
|
||||
if fa.Status == "" {
|
||||
fa.Status = "pending"
|
||||
}
|
||||
if fa.Confidence == 0 {
|
||||
fa.Confidence = 0.0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Habit represents a habit that can be tracked
|
||||
type Habit struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Basic habit information
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Category string `json:"category" gorm:"default:personal"` // health, productivity, learning, personal
|
||||
|
||||
// Habit tracking
|
||||
TargetFrequency int `json:"target_frequency"` // e.g., 7 times per week
|
||||
FrequencyUnit string `json:"frequency_unit"` // daily, weekly, monthly
|
||||
TargetValue float64 `json:"target_value"` // e.g., 30 minutes, 8 glasses
|
||||
Unit string `json:"unit"` // minutes, glasses, pages, etc.
|
||||
|
||||
// Schedule
|
||||
TimeOfDay string `json:"time_of_day"` // morning, afternoon, evening, night
|
||||
DaysOfWeek []string `json:"days_of_week" gorm:"serializer:json"` // ["monday", "tuesday", etc.]
|
||||
|
||||
// Status and settings
|
||||
IsActive bool `json:"is_active" gorm:"default:true"`
|
||||
IsPublic bool `json:"is_public" gorm:"default:false"`
|
||||
Streak int `json:"streak" gorm:"default:0"`
|
||||
LongestStreak int `json:"longest_streak" gorm:"default:0"`
|
||||
CompletionRate float64 `json:"completion_rate" gorm:"default:0"` // percentage
|
||||
|
||||
// Relationships
|
||||
GoalID *uint `json:"goal_id,omitempty"`
|
||||
Goal *Goal `json:"goal,omitempty" gorm:"foreignKey:GoalID"`
|
||||
HabitEntries []HabitEntry `json:"habit_entries,omitempty" gorm:"foreignKey:HabitID"`
|
||||
HabitTags []HabitTag `json:"habit_tags,omitempty" gorm:"foreignKey:HabitID"`
|
||||
}
|
||||
|
||||
// HabitEntry represents a single completion of a habit
|
||||
type HabitEntry struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
HabitID uint `json:"habit_id" gorm:"not null;index"`
|
||||
Habit Habit `json:"habit,omitempty" gorm:"foreignKey:HabitID"`
|
||||
|
||||
EntryDate time.Time `json:"entry_date" gorm:"not null;index"`
|
||||
Value float64 `json:"value"` // actual value completed
|
||||
TargetValue float64 `json:"target_value"` // target value for this entry
|
||||
Unit string `json:"unit"`
|
||||
Notes string `json:"notes" gorm:"type:text"`
|
||||
IsCompleted bool `json:"is_completed" gorm:"default:false"`
|
||||
Quality int `json:"quality" gorm:"default:3"` // 1-5 rating
|
||||
TimeSpent int `json:"time_spent"` // minutes spent
|
||||
Location string `json:"location"`
|
||||
Mood string `json:"mood"` // happy, neutral, stressed, etc.
|
||||
}
|
||||
|
||||
// GoalTag represents tags for goals
|
||||
type GoalTag struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
GoalID uint `json:"goal_id" gorm:"not null;index"`
|
||||
TagID uint `json:"tag_id" gorm:"not null;index"`
|
||||
Goal Goal `json:"goal,omitempty" gorm:"foreignKey:GoalID"`
|
||||
Tag Tag `json:"tag,omitempty" gorm:"foreignKey:TagID"`
|
||||
}
|
||||
|
||||
// HabitTag represents tags for habits
|
||||
type HabitTag struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
HabitID uint `json:"habit_id" gorm:"not null;index"`
|
||||
TagID uint `json:"tag_id" gorm:"not null;index"`
|
||||
Habit Habit `json:"habit,omitempty" gorm:"foreignKey:HabitID"`
|
||||
Tag Tag `json:"tag,omitempty" gorm:"foreignKey:TagID"`
|
||||
}
|
||||
|
||||
// GoalTemplate represents templates for creating goals
|
||||
type GoalTemplate struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Category string `json:"category"`
|
||||
Unit string `json:"unit"`
|
||||
TargetValue float64 `json:"target_value"`
|
||||
Duration int `json:"duration"` // suggested duration in days
|
||||
IsPublic bool `json:"is_public" gorm:"default:false"`
|
||||
UsageCount int `json:"usage_count" gorm:"default:0"`
|
||||
|
||||
// Template milestones
|
||||
Milestones []GoalTemplateMilestone `json:"milestones,omitempty" gorm:"foreignKey:GoalTemplateID"`
|
||||
}
|
||||
|
||||
// GoalTemplateMilestone represents milestones in goal templates
|
||||
type GoalTemplateMilestone struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
GoalTemplateID uint `json:"goal_template_id" gorm:"not null;index"`
|
||||
GoalTemplate GoalTemplate `json:"goal_template,omitempty" gorm:"foreignKey:GoalTemplateID"`
|
||||
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
TargetValue float64 `json:"target_value"`
|
||||
Unit string `json:"unit"`
|
||||
DayOffset int `json:"day_offset"` // days from start date
|
||||
SortOrder int `json:"sort_order" gorm:"default:0"`
|
||||
}
|
||||
|
||||
// BeforeCreate hooks
|
||||
func (h *Habit) BeforeCreate(tx *gorm.DB) error {
|
||||
if h.Category == "" {
|
||||
h.Category = "personal"
|
||||
}
|
||||
if h.FrequencyUnit == "" {
|
||||
h.FrequencyUnit = "daily"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (he *HabitEntry) BeforeCreate(tx *gorm.DB) error {
|
||||
// Set completion based on value vs target
|
||||
if he.Value >= he.TargetValue {
|
||||
he.IsCompleted = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate hooks
|
||||
func (h *Habit) BeforeUpdate(tx *gorm.DB) error {
|
||||
// Update completion rate and streak
|
||||
h.updateStreakAndRate()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
// updateStreakAndRate calculates current streak and completion rate
|
||||
func (h *Habit) updateStreakAndRate() {
|
||||
// This would typically involve querying habit entries
|
||||
// For now, we'll keep the existing values
|
||||
// In a real implementation, you'd calculate based on recent entries
|
||||
}
|
||||
|
||||
// GetTodayEntry gets today's habit entry if it exists
|
||||
func (h *Habit) GetTodayEntry() *HabitEntry {
|
||||
// This would typically query the database
|
||||
// For now, return nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetWeeklyStreak gets the current weekly streak
|
||||
func (h *Habit) GetWeeklyStreak() int {
|
||||
// This would calculate based on habit entries
|
||||
// For now, return the stored streak
|
||||
return h.Streak
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// IntegrationType represents the type of integration
|
||||
type IntegrationType string
|
||||
|
||||
const (
|
||||
IntegrationSlack IntegrationType = "slack"
|
||||
IntegrationDiscord IntegrationType = "discord"
|
||||
IntegrationNotion IntegrationType = "notion"
|
||||
IntegrationPocket IntegrationType = "pocket"
|
||||
IntegrationTodoist IntegrationType = "todoist"
|
||||
IntegrationGoogle IntegrationType = "google"
|
||||
IntegrationGitHub IntegrationType = "github"
|
||||
IntegrationTwitter IntegrationType = "twitter"
|
||||
IntegrationReddit IntegrationType = "reddit"
|
||||
IntegrationObsidian IntegrationType = "obsidian"
|
||||
)
|
||||
|
||||
// IntegrationStatus represents the status of an integration
|
||||
type IntegrationStatus string
|
||||
|
||||
const (
|
||||
StatusActive IntegrationStatus = "active"
|
||||
StatusInactive IntegrationStatus = "inactive"
|
||||
StatusError IntegrationStatus = "error"
|
||||
StatusPending IntegrationStatus = "pending"
|
||||
)
|
||||
|
||||
// Integration represents a third-party service integration
|
||||
type Integration struct {
|
||||
ID string `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
|
||||
UserID string `json:"userId" gorm:"not null;index;type:uuid"`
|
||||
Type IntegrationType `json:"type" gorm:"not null;index"`
|
||||
Status IntegrationStatus `json:"status" gorm:"not null;default:'pending'"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
|
||||
// Configuration stored as JSON
|
||||
Config IntegrationConfig `json:"config" gorm:"type:jsonb"`
|
||||
|
||||
// Authentication tokens (encrypted)
|
||||
AccessToken string `json:"-" gorm:"type:text"` // Encrypted
|
||||
RefreshToken string `json:"-" gorm:"type:text"` // Encrypted
|
||||
|
||||
// Sync settings
|
||||
SyncEnabled bool `json:"syncEnabled" gorm:"default:true"`
|
||||
LastSyncAt *time.Time `json:"lastSyncAt"`
|
||||
SyncInterval int `json:"syncInterval"` // in minutes, 0 = manual
|
||||
|
||||
// Webhook settings
|
||||
WebhookURL string `json:"webhookUrl" gorm:"type:text"`
|
||||
WebhookSecret string `json:"-" gorm:"type:text"` // Encrypted
|
||||
|
||||
// Statistics
|
||||
SyncCount int `json:"syncCount" gorm:"default:0"`
|
||||
ErrorCount int `json:"errorCount" gorm:"default:0"`
|
||||
LastError string `json:"lastError" gorm:"type:text"`
|
||||
|
||||
// Timestamps
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Relationships
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
SyncLogs []SyncLog `json:"syncLogs,omitempty" gorm:"foreignKey:IntegrationID"`
|
||||
}
|
||||
|
||||
// IntegrationConfig holds configuration specific to each integration type
|
||||
type IntegrationConfig struct {
|
||||
// Slack configuration
|
||||
SlackConfig *SlackConfig `json:"slackConfig,omitempty"`
|
||||
|
||||
// Discord configuration
|
||||
DiscordConfig *DiscordConfig `json:"discordConfig,omitempty"`
|
||||
|
||||
// Notion configuration
|
||||
NotionConfig *NotionConfig `json:"notionConfig,omitempty"`
|
||||
|
||||
// Google configuration
|
||||
GoogleConfig *GoogleConfig `json:"googleConfig,omitempty"`
|
||||
|
||||
// Pocket configuration
|
||||
PocketConfig *PocketConfig `json:"pocketConfig,omitempty"`
|
||||
|
||||
// Todoist configuration
|
||||
TodoistConfig *TodoistConfig `json:"todoistConfig,omitempty"`
|
||||
|
||||
// GitHub configuration
|
||||
GitHubConfig *GitHubConfig `json:"gitHubConfig,omitempty"`
|
||||
|
||||
// Twitter configuration
|
||||
TwitterConfig *TwitterConfig `json:"twitterConfig,omitempty"`
|
||||
|
||||
// Reddit configuration
|
||||
RedditConfig *RedditConfig `json:"redditConfig,omitempty"`
|
||||
|
||||
// Obsidian configuration
|
||||
ObsidianConfig *ObsidianConfig `json:"obsidianConfig,omitempty"`
|
||||
}
|
||||
|
||||
// SlackConfig holds Slack-specific configuration
|
||||
type SlackConfig struct {
|
||||
TeamID string `json:"teamId"`
|
||||
TeamName string `json:"teamName"`
|
||||
ChannelID string `json:"channelId"`
|
||||
ChannelName string `json:"channelName"`
|
||||
BotUserID string `json:"botUserId"`
|
||||
Scopes []string `json:"scopes"`
|
||||
|
||||
// Notification settings
|
||||
NotifyTasks bool `json:"notifyTasks"`
|
||||
NotifyBookmarks bool `json:"notifyBookmarks"`
|
||||
NotifyNotes bool `json:"notifyNotes"`
|
||||
NotifyDeadlines bool `json:"notifyDeadlines"`
|
||||
NotifyTimeEntries bool `json:"notifyTimeEntries"`
|
||||
}
|
||||
|
||||
// DiscordConfig holds Discord-specific configuration
|
||||
type DiscordConfig struct {
|
||||
GuildID string `json:"guildId"`
|
||||
GuildName string `json:"guildName"`
|
||||
ChannelID string `json:"channelId"`
|
||||
ChannelName string `json:"channelName"`
|
||||
BotUserID string `json:"botUserId"`
|
||||
Scopes []string `json:"scopes"`
|
||||
|
||||
// Notification settings
|
||||
NotifyTasks bool `json:"notifyTasks"`
|
||||
NotifyBookmarks bool `json:"notifyBookmarks"`
|
||||
NotifyNotes bool `json:"notifyNotes"`
|
||||
NotifyDeadlines bool `json:"notifyDeadlines"`
|
||||
NotifyTimeEntries bool `json:"notifyTimeEntries"`
|
||||
}
|
||||
|
||||
// NotionConfig holds Notion-specific configuration
|
||||
type NotionConfig struct {
|
||||
DatabaseID string `json:"databaseId"`
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
WorkspaceName string `json:"workspaceName"`
|
||||
|
||||
// Sync settings
|
||||
SyncBookmarks bool `json:"syncBookmarks"`
|
||||
SyncTasks bool `json:"syncTasks"`
|
||||
SyncNotes bool `json:"syncNotes"`
|
||||
SyncFiles bool `json:"syncFiles"`
|
||||
|
||||
// Mapping settings
|
||||
BookmarkDatabaseID string `json:"bookmarkDatabaseId"`
|
||||
TaskDatabaseID string `json:"taskDatabaseId"`
|
||||
NoteDatabaseID string `json:"noteDatabaseId"`
|
||||
FileDatabaseID string `json:"fileDatabaseId"`
|
||||
}
|
||||
|
||||
// GoogleConfig holds Google-specific configuration
|
||||
type GoogleConfig struct {
|
||||
// Google Drive
|
||||
DriveEnabled bool `json:"driveEnabled"`
|
||||
DriveFolderID string `json:"driveFolderId"`
|
||||
|
||||
// Google Calendar
|
||||
CalendarEnabled bool `json:"calendarEnabled"`
|
||||
CalendarIDs []string `json:"calendarIds"`
|
||||
|
||||
// Google Docs
|
||||
DocsEnabled bool `json:"docsEnabled"`
|
||||
|
||||
// Sync settings
|
||||
SyncBookmarks bool `json:"syncBookmarks"`
|
||||
SyncTasks bool `json:"syncTasks"`
|
||||
SyncNotes bool `json:"syncNotes"`
|
||||
SyncFiles bool `json:"syncFiles"`
|
||||
SyncCalendar bool `json:"syncCalendar"`
|
||||
}
|
||||
|
||||
// PocketConfig holds Pocket-specific configuration
|
||||
type PocketConfig struct {
|
||||
Username string `json:"username"`
|
||||
|
||||
// Sync settings
|
||||
SyncBookmarks bool `json:"syncBookmarks"`
|
||||
SyncTags bool `json:"syncTags"`
|
||||
ImportAll bool `json:"importAll"`
|
||||
}
|
||||
|
||||
// TodoistConfig holds Todoist-specific configuration
|
||||
type TodoistConfig struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
ProjectName string `json:"projectName"`
|
||||
|
||||
// Sync settings
|
||||
SyncTasks bool `json:"syncTasks"`
|
||||
SyncProjects bool `json:"syncProjects"`
|
||||
SyncLabels bool `json:"syncLabels"`
|
||||
ImportAll bool `json:"importAll"`
|
||||
}
|
||||
|
||||
// GitHubConfig holds GitHub-specific configuration
|
||||
type GitHubConfig struct {
|
||||
Username string `json:"username"`
|
||||
RepoSync bool `json:"repoSync"`
|
||||
IssueSync bool `json:"issueSync"`
|
||||
PRSync bool `json:"prSync"`
|
||||
StarSync bool `json:"starSync"`
|
||||
WatchSync bool `json:"watchSync"`
|
||||
}
|
||||
|
||||
// TwitterConfig holds Twitter-specific configuration
|
||||
type TwitterConfig struct {
|
||||
Username string `json:"username"`
|
||||
SyncTweets bool `json:"syncTweets"`
|
||||
SyncLikes bool `json:"syncLikes"`
|
||||
SyncBookmarks bool `json:"syncBookmarks"`
|
||||
}
|
||||
|
||||
// RedditConfig holds Reddit-specific configuration
|
||||
type RedditConfig struct {
|
||||
Username string `json:"username"`
|
||||
SyncPosts bool `json:"syncPosts"`
|
||||
SyncComments bool `json:"syncComments"`
|
||||
SyncSaved bool `json:"syncSaved"`
|
||||
SyncUpvoted bool `json:"syncUpvoted"`
|
||||
}
|
||||
|
||||
// ObsidianConfig holds Obsidian-specific configuration
|
||||
type ObsidianConfig struct {
|
||||
VaultPath string `json:"vaultPath"`
|
||||
VaultName string `json:"vaultName"`
|
||||
SyncNotes bool `json:"syncNotes"`
|
||||
SyncBookmarks bool `json:"syncBookmarks"`
|
||||
SyncTasks bool `json:"syncTasks"`
|
||||
AutoSync bool `json:"autoSync"`
|
||||
}
|
||||
|
||||
// SyncLog represents a sync operation log
|
||||
type SyncLog struct {
|
||||
ID string `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
|
||||
IntegrationID string `json:"integrationId" gorm:"not null;index;type:uuid"`
|
||||
Type string `json:"type"` // "full", "incremental", "manual", "webhook"
|
||||
Status string `json:"status"` // "success", "error", "partial"
|
||||
|
||||
// Sync statistics
|
||||
ItemsProcessed int `json:"itemsProcessed"`
|
||||
ItemsCreated int `json:"itemsCreated"`
|
||||
ItemsUpdated int `json:"itemsUpdated"`
|
||||
ItemsDeleted int `json:"itemsDeleted"`
|
||||
ItemsSkipped int `json:"itemsSkipped"`
|
||||
|
||||
// Timing
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
CompletedAt *time.Time `json:"completedAt"`
|
||||
Duration int `json:"duration"` // in seconds
|
||||
|
||||
// Error details
|
||||
ErrorMessage string `json:"errorMessage" gorm:"type:text"`
|
||||
ErrorDetails string `json:"errorDetails" gorm:"type:text"`
|
||||
|
||||
// Additional data
|
||||
SyncData string `json:"syncData" gorm:"type:jsonb"`
|
||||
|
||||
// Timestamps
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
|
||||
// Relationships
|
||||
Integration Integration `json:"integration,omitempty" gorm:"foreignKey:IntegrationID"`
|
||||
}
|
||||
|
||||
// WebhookEvent represents an incoming webhook event
|
||||
type WebhookEvent struct {
|
||||
ID string `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
|
||||
IntegrationID string `json:"integrationId" gorm:"not null;index;type:uuid"`
|
||||
Type string `json:"type"` // "slack", "discord", etc.
|
||||
EventType string `json:"eventType"` // "message", "reaction_added", etc.
|
||||
|
||||
// Event data
|
||||
Payload string `json:"payload" gorm:"type:jsonb"`
|
||||
Processed bool `json:"processed" gorm:"default:false"`
|
||||
|
||||
// Processing details
|
||||
ProcessedAt *time.Time `json:"processedAt"`
|
||||
ErrorMessage string `json:"errorMessage" gorm:"type:text"`
|
||||
|
||||
// Timestamps
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
|
||||
// Relationships
|
||||
Integration Integration `json:"integration,omitempty" gorm:"foreignKey:IntegrationID"`
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// WikiPage represents a page in the knowledge base/wiki
|
||||
type WikiPage struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Basic page information
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Slug string `json:"slug" gorm:"not null;uniqueIndex"`
|
||||
Content string `json:"content" gorm:"type:text"`
|
||||
Summary string `json:"summary" gorm:"type:text"`
|
||||
Status string `json:"status" gorm:"default:draft"` // draft, published, archived
|
||||
|
||||
// Organization
|
||||
CategoryID *uint `json:"category_id,omitempty"`
|
||||
Category *Category `json:"category,omitempty" gorm:"foreignKey:CategoryID"`
|
||||
ParentID *uint `json:"parent_id,omitempty"`
|
||||
Parent *WikiPage `json:"parent,omitempty" gorm:"foreignKey:ParentID"`
|
||||
Children []WikiPage `json:"children,omitempty" gorm:"foreignKey:ParentID"`
|
||||
|
||||
// Metadata
|
||||
Tags []Tag `json:"tags,omitempty" gorm:"many2many:wiki_page_tags;"`
|
||||
Keywords []string `json:"keywords" gorm:"serializer:json"`
|
||||
ReadingTime int `json:"reading_time"` // estimated minutes
|
||||
WordCount int `json:"word_count"`
|
||||
ViewCount int `json:"view_count" gorm:"default:0"`
|
||||
LastViewedAt *time.Time `json:"last_viewed_at,omitempty"`
|
||||
IsPublic bool `json:"is_public" gorm:"default:false"`
|
||||
IsTemplate bool `json:"is_template" gorm:"default:false"`
|
||||
TemplateID *uint `json:"template_id,omitempty"`
|
||||
Template *WikiPage `json:"template,omitempty" gorm:"foreignKey:TemplateID"`
|
||||
|
||||
// Collaboration
|
||||
IsCollaborative bool `json:"is_collaborative" gorm:"default:false"`
|
||||
Collaborators []User `json:"collaborators,omitempty" gorm:"many2many:wiki_collaborators;"`
|
||||
LastEditedBy *uint `json:"last_edited_by,omitempty"`
|
||||
LastEditedUser *User `json:"last_edited_user,omitempty" gorm:"foreignKey:LastEditedBy"`
|
||||
EditCount int `json:"edit_count" gorm:"default:0"`
|
||||
|
||||
// Relationships
|
||||
Versions []WikiVersion `json:"versions,omitempty" gorm:"foreignKey:WikiPageID"`
|
||||
Backlinks []WikiBacklink `json:"backlinks,omitempty" gorm:"foreignKey:TargetPageID"`
|
||||
Attachments []WikiAttachment `json:"attachments,omitempty" gorm:"foreignKey:WikiPageID"`
|
||||
Bookmarks []Bookmark `json:"bookmarks,omitempty" gorm:"foreignKey:WikiPageID"`
|
||||
Notes []Note `json:"notes,omitempty" gorm:"foreignKey:WikiPageID"`
|
||||
}
|
||||
|
||||
// Category represents a category for organizing wiki pages
|
||||
type Category struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Slug string `json:"slug" gorm:"not null;uniqueIndex"`
|
||||
Description string `json:"description"`
|
||||
Color string `json:"color" gorm:"default:#6366f1"`
|
||||
Icon string `json:"icon"`
|
||||
ParentID *uint `json:"parent_id,omitempty"`
|
||||
Parent *Category `json:"parent,omitempty" gorm:"foreignKey:ParentID"`
|
||||
Children []Category `json:"children,omitempty" gorm:"foreignKey:ParentID"`
|
||||
IsPublic bool `json:"is_public" gorm:"default:false"`
|
||||
SortOrder int `json:"sort_order" gorm:"default:0"`
|
||||
|
||||
// Relationships
|
||||
Pages []WikiPage `json:"pages,omitempty" gorm:"foreignKey:CategoryID"`
|
||||
}
|
||||
|
||||
// WikiVersion represents a version history of a wiki page
|
||||
type WikiVersion struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
WikiPageID uint `json:"wiki_page_id" gorm:"not null;index"`
|
||||
WikiPage WikiPage `json:"wiki_page,omitempty" gorm:"foreignKey:WikiPageID"`
|
||||
|
||||
VersionNumber int `json:"version_number" gorm:"not null"`
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Content string `json:"content" gorm:"type:text"`
|
||||
Summary string `json:"summary" gorm:"type:text"`
|
||||
ChangeLog string `json:"change_log" gorm:"type:text"`
|
||||
|
||||
// Author information
|
||||
AuthorID uint `json:"author_id" gorm:"not null"`
|
||||
Author User `json:"author,omitempty" gorm:"foreignKey:AuthorID"`
|
||||
|
||||
// Version metadata
|
||||
WordCount int `json:"word_count"`
|
||||
CharactersAdded int `json:"characters_added"`
|
||||
CharactersRemoved int `json:"characters_removed"`
|
||||
IsMinorChange bool `json:"is_minor_change" gorm:"default:false"`
|
||||
}
|
||||
|
||||
// WikiBacklink represents a link between wiki pages
|
||||
type WikiBacklink struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
SourcePageID uint `json:"source_page_id" gorm:"not null;index"`
|
||||
SourcePage WikiPage `json:"source_page,omitempty" gorm:"foreignKey:SourcePageID"`
|
||||
TargetPageID uint `json:"target_page_id" gorm:"not null;index"`
|
||||
TargetPage WikiPage `json:"target_page,omitempty" gorm:"foreignKey:TargetPageID"`
|
||||
|
||||
LinkText string `json:"link_text"`
|
||||
Context string `json:"context" gorm:"type:text"` // Surrounding text where the link appears
|
||||
}
|
||||
|
||||
// WikiAttachment represents files attached to wiki pages
|
||||
type WikiAttachment struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
WikiPageID uint `json:"wiki_page_id" gorm:"not null;index"`
|
||||
WikiPage WikiPage `json:"wiki_page,omitempty" gorm:"foreignKey:WikiPageID"`
|
||||
|
||||
FileName string `json:"file_name" gorm:"not null"`
|
||||
OriginalName string `json:"original_name" gorm:"not null"`
|
||||
FilePath string `json:"file_path" gorm:"not null"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Width int `json:"width"` // For images
|
||||
Height int `json:"height"` // For images
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Description string `json:"description"`
|
||||
IsPublic bool `json:"is_public" gorm:"default:false"`
|
||||
}
|
||||
|
||||
// Template represents reusable page templates
|
||||
type Template struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Slug string `json:"slug" gorm:"not null;uniqueIndex"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content" gorm:"type:text"`
|
||||
Category string `json:"category"` // meeting, project, documentation, etc.
|
||||
IsPublic bool `json:"is_public" gorm:"default:false"`
|
||||
UsageCount int `json:"usage_count" gorm:"default:0"`
|
||||
|
||||
// Template variables
|
||||
Variables []TemplateVariable `json:"variables,omitempty" gorm:"foreignKey:TemplateID"`
|
||||
}
|
||||
|
||||
// TemplateVariable represents variables in templates
|
||||
type TemplateVariable struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
TemplateID uint `json:"template_id" gorm:"not null;index"`
|
||||
Template Template `json:"template,omitempty" gorm:"foreignKey:TemplateID"`
|
||||
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Type string `json:"type" gorm:"not null"` // text, number, date, select
|
||||
DefaultValue string `json:"default_value"`
|
||||
Required bool `json:"required" gorm:"default:false"`
|
||||
Description string `json:"description"`
|
||||
Options string `json:"options" gorm:"serializer:json"` // For select type
|
||||
}
|
||||
|
||||
// BeforeCreate hooks
|
||||
func (w *WikiPage) BeforeCreate(tx *gorm.DB) error {
|
||||
if w.Status == "" {
|
||||
w.Status = "draft"
|
||||
}
|
||||
if w.Slug == "" && w.Title != "" {
|
||||
w.Slug = generateSlug(w.Title)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Category) BeforeCreate(tx *gorm.DB) error {
|
||||
if c.Slug == "" && c.Name != "" {
|
||||
c.Slug = generateSlug(c.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Template) BeforeCreate(tx *gorm.DB) error {
|
||||
if t.Slug == "" && t.Name != "" {
|
||||
t.Slug = generateSlug(t.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate hooks
|
||||
func (w *WikiPage) BeforeUpdate(tx *gorm.DB) error {
|
||||
if w.Title != "" {
|
||||
w.Slug = generateSlug(w.Title)
|
||||
}
|
||||
if w.Content != "" {
|
||||
w.WordCount = len(strings.Fields(w.Content))
|
||||
w.ReadingTime = estimateReadingTime(w.WordCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func generateSlug(title string) string {
|
||||
// Simple slug generation - in production, you'd want more sophisticated handling
|
||||
slug := strings.ToLower(title)
|
||||
slug = strings.ReplaceAll(slug, " ", "-")
|
||||
slug = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(slug, "")
|
||||
slug = regexp.MustCompile(`-+`).ReplaceAllString(slug, "-")
|
||||
slug = strings.Trim(slug, "-")
|
||||
return slug
|
||||
}
|
||||
|
||||
func estimateReadingTime(wordCount int) int {
|
||||
readingSpeed := 225 // words per minute
|
||||
readingTime := wordCount / readingSpeed
|
||||
if readingTime < 1 {
|
||||
readingTime = 1
|
||||
}
|
||||
return readingTime
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LearningPath represents a structured learning path
|
||||
type LearningPath struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Basic information
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category" gorm:"not null"` // programming, design, business, etc.
|
||||
Difficulty string `json:"difficulty" gorm:"default:beginner"` // beginner, intermediate, advanced
|
||||
|
||||
// Metadata
|
||||
Duration string `json:"duration"` // estimated time to complete
|
||||
Thumbnail string `json:"thumbnail"` // cover image
|
||||
IsPublished bool `json:"is_published" gorm:"default:false"`
|
||||
IsFeatured bool `json:"is_featured" gorm:"default:false"`
|
||||
|
||||
// Creator information
|
||||
CreatorID uint `json:"creator_id" gorm:"not null;index"`
|
||||
Creator User `json:"creator,omitempty" gorm:"foreignKey:CreatorID"`
|
||||
|
||||
// Relationships
|
||||
Modules []LearningModule `json:"modules,omitempty" gorm:"foreignKey:LearningPathID"`
|
||||
Courses []LearningPathCourse `json:"courses,omitempty" gorm:"foreignKey:LearningPathID"`
|
||||
Tags []Tag `json:"tags,omitempty" gorm:"many2many:learning_path_tags;"`
|
||||
Enrollments []Enrollment `json:"enrollments,omitempty" gorm:"foreignKey:LearningPathID"`
|
||||
|
||||
// Statistics
|
||||
EnrollmentCount int `json:"enrollment_count" gorm:"default:0"`
|
||||
Rating float64 `json:"rating" gorm:"default:0"`
|
||||
ReviewCount int `json:"review_count" gorm:"default:0"`
|
||||
}
|
||||
|
||||
// LearningModule represents a module within a learning path
|
||||
type LearningModule struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Basic information
|
||||
LearningPathID uint `json:"learning_path_id" gorm:"not null;index"`
|
||||
LearningPath LearningPath `json:"learning_path,omitempty" gorm:"foreignKey:LearningPathID"`
|
||||
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content" gorm:"type:text"`
|
||||
Order int `json:"order" gorm:"not null"`
|
||||
|
||||
// Module type
|
||||
ModuleType string `json:"module_type" gorm:"default:lesson"` // lesson, project, quiz, video, reading
|
||||
|
||||
// Resources
|
||||
Resources []ModuleResource `json:"resources,omitempty" gorm:"foreignKey:LearningModuleID"`
|
||||
|
||||
// Completion tracking
|
||||
EstimatedDuration string `json:"estimated_duration"`
|
||||
IsRequired bool `json:"is_required" gorm:"default:true"`
|
||||
}
|
||||
|
||||
// ModuleResource represents a resource within a learning module
|
||||
type ModuleResource struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
LearningModuleID uint `json:"learning_module_id" gorm:"not null;index"`
|
||||
LearningModule LearningModule `json:"learning_module,omitempty" gorm:"foreignKey:LearningModuleID"`
|
||||
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type" gorm:"not null"` // video, article, book, tool, download
|
||||
Description string `json:"description"`
|
||||
Order int `json:"order" gorm:"not null"`
|
||||
|
||||
// External resource metadata
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Duration string `json:"duration"`
|
||||
IsExternal bool `json:"is_external" gorm:"default:true"`
|
||||
}
|
||||
|
||||
// Enrollment represents a user's enrollment in a learning path
|
||||
type Enrollment struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
LearningPathID uint `json:"learning_path_id" gorm:"not null;index"`
|
||||
LearningPath LearningPath `json:"learning_path,omitempty" gorm:"foreignKey:LearningPathID"`
|
||||
CourseID *uint `json:"course_id,omitempty"` // for direct course enrollment
|
||||
Course *Course `json:"course,omitempty" gorm:"foreignKey:CourseID"`
|
||||
|
||||
// Enrollment status
|
||||
Status string `json:"status" gorm:"default:enrolled"` // enrolled, in_progress, completed, dropped
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
|
||||
// Progress tracking
|
||||
Progress float64 `json:"progress" gorm:"default:0"` // percentage 0-100
|
||||
CompletedModules []uint `json:"completed_modules" gorm:"serializer:json"`
|
||||
CurrentModuleID *uint `json:"current_module_id"`
|
||||
|
||||
// User feedback
|
||||
Rating *float64 `json:"rating"`
|
||||
Review string `json:"review"`
|
||||
ReviewDate *time.Time `json:"review_date"`
|
||||
}
|
||||
|
||||
// Progress represents a user's progress in a specific module
|
||||
type Progress struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
EnrollmentID uint `json:"enrollment_id" gorm:"not null;index"`
|
||||
LearningModuleID uint `json:"learning_module_id" gorm:"not null;index"`
|
||||
|
||||
// Progress status
|
||||
Status string `json:"status" gorm:"default:not_started"` // not_started, in_progress, completed
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
|
||||
// Progress details
|
||||
TimeSpent int `json:"time_spent"` // minutes
|
||||
ProgressData string `json:"progress_data" gorm:"type:json"` // additional progress data
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MarketplaceItem represents an item in the knowledge marketplace
|
||||
type MarketplaceItem struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Creator information
|
||||
SellerID uint `json:"seller_id" gorm:"not null;index"`
|
||||
Seller User `json:"seller,omitempty" gorm:"foreignKey:SellerID"`
|
||||
|
||||
// Basic information
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Category string `json:"category" gorm:"not null"` // course, template, tool, guide, resource
|
||||
|
||||
// Content type and reference
|
||||
ContentType string `json:"content_type" gorm:"not null"` // bookmark_collection, note_template, course, learning_path, tool
|
||||
ContentID *uint `json:"content_id,omitempty"` // Reference to actual content
|
||||
ContentURL string `json:"content_url"` // Download/access URL
|
||||
PreviewURL string `json:"preview_url"` // Preview/demo URL
|
||||
Thumbnail string `json:"thumbnail"` // Item thumbnail
|
||||
|
||||
// Pricing
|
||||
Price float64 `json:"price" gorm:"default:0"` // Price in USD
|
||||
Currency string `json:"currency" gorm:"default:USD"` // Currency code
|
||||
IsFree bool `json:"is_free" gorm:"default:false"` // Free item
|
||||
Subscription bool `json:"subscription" gorm:"default:false"` // Subscription-based
|
||||
SubscriptionPrice float64 `json:"subscription_price" gorm:"default:0"` // Monthly subscription price
|
||||
|
||||
// Ratings and reviews
|
||||
Rating float64 `json:"rating" gorm:"default:0"` // Average rating (1-5)
|
||||
ReviewCount int `json:"review_count" gorm:"default:0"` // Number of reviews
|
||||
DownloadCount int `json:"download_count" gorm:"default:0"` // Number of downloads
|
||||
|
||||
// Status and visibility
|
||||
Status string `json:"status" gorm:"default:draft"` // draft, published, suspended, removed
|
||||
IsFeatured bool `json:"is_featured" gorm:"default:false"` // Featured item
|
||||
IsApproved bool `json:"is_approved" gorm:"default:false"` // Admin approved
|
||||
ApprovedAt *time.Time `json:"approved_at,omitempty"`
|
||||
ApprovedBy *uint `json:"approved_by,omitempty"`
|
||||
Approver *User `json:"approver,omitempty" gorm:"foreignKey:ApprovedBy"`
|
||||
|
||||
// Tags and metadata
|
||||
Tags []MarketplaceTag `json:"tags,omitempty" gorm:"many2many:marketplace_item_tags;"`
|
||||
License string `json:"license" gorm:"default:standard"` // License type
|
||||
Version string `json:"version" gorm:"default:1.0"` // Version
|
||||
LastUpdated *time.Time `json:"last_updated,omitempty"`
|
||||
|
||||
// Analytics
|
||||
ViewCount int `json:"view_count" gorm:"default:0"`
|
||||
LastViewedAt *time.Time `json:"last_viewed_at,omitempty"`
|
||||
|
||||
// Relationships
|
||||
Reviews []MarketplaceReview `json:"reviews,omitempty" gorm:"foreignKey:ItemID"`
|
||||
Purchases []MarketplacePurchase `json:"purchases,omitempty" gorm:"foreignKey:ItemID"`
|
||||
}
|
||||
|
||||
// MarketplaceTag represents tags for marketplace items
|
||||
type MarketplaceTag struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
Name string `json:"name" gorm:"uniqueIndex;not null"`
|
||||
Description string `json:"description"`
|
||||
Color string `json:"color" gorm:"default:#6366f1"` // Tag color
|
||||
UsageCount int `json:"usage_count" gorm:"default:0"`
|
||||
}
|
||||
|
||||
// MarketplaceReview represents a review for a marketplace item
|
||||
type MarketplaceReview struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Review information
|
||||
ItemID uint `json:"item_id" gorm:"not null;index"`
|
||||
Item MarketplaceItem `json:"item,omitempty" gorm:"foreignKey:ItemID"`
|
||||
ReviewerID uint `json:"reviewer_id" gorm:"not null;index"`
|
||||
Reviewer User `json:"reviewer,omitempty" gorm:"foreignKey:ReviewerID"`
|
||||
|
||||
// Review content
|
||||
Rating int `json:"rating" gorm:"not null;check:rating >= 1 AND rating <= 5"` // 1-5 stars
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content" gorm:"type:text"`
|
||||
|
||||
// Review metadata
|
||||
HelpfulCount int `json:"helpful_count" gorm:"default:0"`
|
||||
IsVerified bool `json:"is_verified" gorm:"default:false"` // Verified purchase
|
||||
PurchaseID *uint `json:"purchase_id,omitempty"`
|
||||
ReviewedAt time.Time `json:"reviewed_at"`
|
||||
|
||||
// Status
|
||||
Status string `json:"status" gorm:"default:published"` // published, hidden, removed
|
||||
}
|
||||
|
||||
// MarketplacePurchase represents a purchase from the marketplace
|
||||
type MarketplacePurchase struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Purchase information
|
||||
ItemID uint `json:"item_id" gorm:"not null;index"`
|
||||
Item MarketplaceItem `json:"item,omitempty" gorm:"foreignKey:ItemID"`
|
||||
BuyerID uint `json:"buyer_id" gorm:"not null;index"`
|
||||
Buyer User `json:"buyer,omitempty" gorm:"foreignKey:BuyerID"`
|
||||
|
||||
// Purchase details
|
||||
Price float64 `json:"price" gorm:"not null"`
|
||||
Currency string `json:"currency" gorm:"default:USD"`
|
||||
PaymentMethod string `json:"payment_method"` // stripe, paypal, crypto
|
||||
TransactionID string `json:"transaction_id" gorm:"uniqueIndex"`
|
||||
|
||||
// License and access
|
||||
LicenseType string `json:"license_type" gorm:"default:personal"` // personal, commercial, enterprise
|
||||
AccessGranted bool `json:"access_granted" gorm:"default:true"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"` // For subscriptions
|
||||
|
||||
// Status
|
||||
Status string `json:"status" gorm:"default:completed"` // pending, completed, refunded, cancelled
|
||||
RefundedAt *time.Time `json:"refunded_at,omitempty"`
|
||||
RefundReason string `json:"refund_reason,omitempty"`
|
||||
|
||||
// Analytics
|
||||
DownloadCount int `json:"download_count" gorm:"default:0"`
|
||||
LastDownloadAt *time.Time `json:"last_download_at,omitempty"`
|
||||
}
|
||||
|
||||
// ContentShare represents shared content links
|
||||
type ContentShare struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Owner information
|
||||
OwnerID uint `json:"owner_id" gorm:"not null;index"`
|
||||
Owner User `json:"owner,omitempty" gorm:"foreignKey:OwnerID"`
|
||||
|
||||
// Content information
|
||||
ContentType string `json:"content_type" gorm:"not null"` // bookmark, note, file, task, goal
|
||||
ContentID uint `json:"content_id" gorm:"not null"`
|
||||
|
||||
// Share settings
|
||||
ShareToken string `json:"share_token" gorm:"uniqueIndex;not null"`
|
||||
ShareURL string `json:"share_url"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Password string `json:"-" gorm:"column:password"` // Optional password protection
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
|
||||
// Access control
|
||||
AllowDownload bool `json:"allow_download" gorm:"default:true"`
|
||||
AllowComment bool `json:"allow_comment" gorm:"default:false"`
|
||||
AllowEdit bool `json:"allow_edit" gorm:"default:false"`
|
||||
|
||||
// Analytics
|
||||
ViewCount int `json:"view_count" gorm:"default:0"`
|
||||
DownloadCount int `json:"download_count" gorm:"default:0"`
|
||||
LastAccessedAt *time.Time `json:"last_accessed_at,omitempty"`
|
||||
|
||||
// Status
|
||||
IsActive bool `json:"is_active" gorm:"default:true"`
|
||||
}
|
||||
|
||||
// BeforeCreate hooks
|
||||
func (m *MarketplaceItem) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.Status == "" {
|
||||
m.Status = "draft"
|
||||
}
|
||||
if m.Currency == "" {
|
||||
m.Currency = "USD"
|
||||
}
|
||||
if m.Version == "" {
|
||||
m.Version = "1.0"
|
||||
}
|
||||
if m.License == "" {
|
||||
m.License = "standard"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MarketplaceReview) BeforeCreate(tx *gorm.DB) error {
|
||||
if r.Status == "" {
|
||||
r.Status = "published"
|
||||
}
|
||||
r.ReviewedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MarketplacePurchase) BeforeCreate(tx *gorm.DB) error {
|
||||
if p.Status == "" {
|
||||
p.Status = "completed"
|
||||
}
|
||||
if p.Currency == "" {
|
||||
p.Currency = "USD"
|
||||
}
|
||||
if p.LicenseType == "" {
|
||||
p.LicenseType = "personal"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ContentShare) BeforeCreate(tx *gorm.DB) error {
|
||||
if s.ShareToken == "" {
|
||||
// Generate a unique share token
|
||||
s.ShareToken = generateShareToken()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper function to generate share tokens
|
||||
func generateShareToken() string {
|
||||
// This should generate a cryptographically secure random token
|
||||
// For now, using a simple implementation
|
||||
return "share_" + time.Now().Format("20060102150405")
|
||||
}
|
||||
@@ -25,5 +25,93 @@ func AutoMigrate() {
|
||||
&Task{},
|
||||
&File{},
|
||||
&Note{},
|
||||
&TimeEntry{},
|
||||
&FileAnalysis{},
|
||||
&ChatSession{},
|
||||
&ChatMessage{},
|
||||
&LearningPath{},
|
||||
&LearningModule{},
|
||||
&ModuleResource{},
|
||||
&Enrollment{},
|
||||
&Progress{},
|
||||
&Course{},
|
||||
&LearningPathCourse{},
|
||||
&CalendarEvent{},
|
||||
&RecurrenceRule{},
|
||||
&CalendarSettings{},
|
||||
// Search models
|
||||
&ContentEmbedding{},
|
||||
&SavedSearch{},
|
||||
&SavedSearchTag{},
|
||||
&SearchAnalytics{},
|
||||
&SearchSuggestion{},
|
||||
// AI Feature models
|
||||
&AISummary{},
|
||||
&AITaskSuggestion{},
|
||||
&UserAISettings{},
|
||||
&AITagSuggestion{},
|
||||
&AIContentGeneration{},
|
||||
&AICodeReview{},
|
||||
&AILearningRecommendation{},
|
||||
// Advanced AI Recommendation models
|
||||
&AIRecommendation{},
|
||||
&UserPreference{},
|
||||
&RecommendationInteraction{},
|
||||
// Integration models
|
||||
&Integration{},
|
||||
&SyncLog{},
|
||||
&WebhookEvent{},
|
||||
// Analytics models
|
||||
&Analytics{},
|
||||
&ProductivityMetrics{},
|
||||
&LearningAnalytics{},
|
||||
&ContentAnalytics{},
|
||||
&GitHubAnalytics{},
|
||||
&HabitAnalytics{},
|
||||
&Goal{},
|
||||
&Milestone{},
|
||||
&AnalyticsReport{},
|
||||
// Social features models
|
||||
&Skill{},
|
||||
&Project{},
|
||||
&ProjectTag{},
|
||||
&SocialLink{},
|
||||
&Follow{},
|
||||
// Team workspace models
|
||||
&Team{},
|
||||
&TeamMember{},
|
||||
&TeamInvitation{},
|
||||
&TeamProject{},
|
||||
&TeamProjectTag{},
|
||||
&TeamBookmark{},
|
||||
&TeamNote{},
|
||||
&TeamTask{},
|
||||
&TeamFile{},
|
||||
&TeamActivity{},
|
||||
// Security models
|
||||
&AuditLog{},
|
||||
// Marketplace models
|
||||
&MarketplaceItem{},
|
||||
&MarketplaceTag{},
|
||||
&MarketplaceReview{},
|
||||
&MarketplacePurchase{},
|
||||
&ContentShare{},
|
||||
// Community models
|
||||
&Challenge{},
|
||||
&ChallengeParticipant{},
|
||||
&ChallengeTeam{},
|
||||
&ChallengeMilestone{},
|
||||
&ChallengeMilestoneCompletion{},
|
||||
&ChallengeResource{},
|
||||
&ChallengeTag{},
|
||||
&Mentorship{},
|
||||
&MentorshipSession{},
|
||||
&MentorshipReview{},
|
||||
&MentorshipMilestone{},
|
||||
&MentorshipRequest{},
|
||||
// YouTube cache models
|
||||
&YouTubeChannelCache{},
|
||||
// Video bookmark models
|
||||
&VideoBookmark{},
|
||||
)
|
||||
}
|
||||
|
||||
+11
-7
@@ -16,22 +16,26 @@ type Note struct {
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Content string `json:"content" gorm:"type:text"`
|
||||
|
||||
|
||||
// Encryption
|
||||
IsEncrypted bool `json:"is_encrypted" gorm:"default:false"`
|
||||
EncryptionKey string `json:"-" gorm:"column:encryption_key"` // User-specific encryption key (optional)
|
||||
|
||||
// Organization
|
||||
Tags []Tag `json:"tags,omitempty" gorm:"many2many:note_tags;"`
|
||||
|
||||
|
||||
// Metadata
|
||||
Description string `json:"description"`
|
||||
IsPublic bool `json:"is_public" gorm:"default:false"`
|
||||
IsPinned bool `json:"is_pinned" gorm:"default:false"`
|
||||
|
||||
|
||||
// Formatting
|
||||
ContentType string `json:"content_type" gorm:"default:markdown"` // markdown, html, plain
|
||||
|
||||
|
||||
// Relationships
|
||||
ParentNoteID *uint `json:"parent_note_id,omitempty"`
|
||||
ParentNote *Note `json:"parent_note,omitempty" gorm:"foreignKey:ParentNoteID"`
|
||||
ParentNoteID *uint `json:"parent_note_id,omitempty"`
|
||||
ParentNote *Note `json:"parent_note,omitempty" gorm:"foreignKey:ParentNoteID"`
|
||||
Subnotes []Note `json:"subnotes,omitempty" gorm:"foreignKey:ParentNoteID"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ContentEmbedding stores vector embeddings for semantic search
|
||||
type ContentEmbedding struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Content reference
|
||||
ContentType string `json:"content_type" gorm:"not null;index"` // 'bookmark', 'task', 'note', 'file'
|
||||
ContentID uint `json:"content_id" gorm:"not null;index"`
|
||||
|
||||
// Embedding data
|
||||
Embedding string `json:"embedding" gorm:"type:text"` // JSON array of floats
|
||||
Model string `json:"model" gorm:"not null"` // AI model used
|
||||
Dimensions int `json:"dimensions" gorm:"not null"` // Vector dimensions
|
||||
TextContent string `json:"text_content" gorm:"type:text"` // Original text for embedding
|
||||
|
||||
// Metadata
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
// SavedSearch represents a user's saved search query
|
||||
type SavedSearch struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Query string `json:"query" gorm:"not null"`
|
||||
Filters string `json:"filters" gorm:"type:json"` // JSON serialized filters
|
||||
Alert bool `json:"alert" gorm:"default:false"`
|
||||
LastRun *time.Time `json:"last_run"`
|
||||
RunCount int `json:"run_count" gorm:"default:0"`
|
||||
IsPublic bool `json:"is_public" gorm:"default:false"`
|
||||
Description string `json:"description"`
|
||||
Tags []SavedSearchTag `json:"tags,omitempty" gorm:"many2many:saved_search_tags;"`
|
||||
}
|
||||
|
||||
// SavedSearchTag represents tags for saved searches
|
||||
type SavedSearchTag struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
Name string `json:"name" gorm:"unique;not null"`
|
||||
Color string `json:"color" gorm:"default:#3b82f6"`
|
||||
}
|
||||
|
||||
// SearchAnalytics stores search analytics data
|
||||
type SearchAnalytics struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
Query string `json:"query" gorm:"not null;index"`
|
||||
Filters string `json:"filters" gorm:"type:json"`
|
||||
ResultsCount int `json:"results_count"`
|
||||
Took int `json:"took"` // Time in milliseconds
|
||||
ContentType string `json:"content_type"`
|
||||
ClickedResultID *uint `json:"clicked_result_id"` // Track which result was clicked
|
||||
SessionID string `json:"session_id" gorm:"index"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
}
|
||||
|
||||
// SearchSuggestion represents search suggestions
|
||||
type SearchSuggestion struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
Text string `json:"text" gorm:"not null;uniqueIndex"`
|
||||
Type string `json:"type" gorm:"not null"` // 'query', 'tag', 'content'
|
||||
Frequency int `json:"frequency" gorm:"default:1"`
|
||||
LastUsed time.Time `json:"last_used"`
|
||||
ContentType *string `json:"content_type,omitempty"`
|
||||
IsPublic bool `json:"is_public" gorm:"default:true"`
|
||||
}
|
||||
|
||||
// BeforeCreate hook for ContentEmbedding
|
||||
func (ce *ContentEmbedding) BeforeCreate(tx *gorm.DB) error {
|
||||
// Set default model if not specified
|
||||
if ce.Model == "" {
|
||||
ce.Model = "text-embedding-ada-002"
|
||||
}
|
||||
|
||||
// Set default dimensions if not specified
|
||||
if ce.Dimensions == 0 {
|
||||
ce.Dimensions = 1536 // Default for OpenAI embeddings
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Skill represents a user's skill
|
||||
type Skill struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Level string `json:"level" gorm:"default:intermediate"` // beginner, intermediate, advanced, expert
|
||||
Category string `json:"category"` // programming, design, business, etc.
|
||||
Endorsements int `json:"endorsements" gorm:"default:0"`
|
||||
Verified bool `json:"verified" gorm:"default:false"`
|
||||
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
// Project represents a user's project showcase
|
||||
type Project struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
RepositoryURL string `json:"repository_url"`
|
||||
LiveURL string `json:"live_url"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Tags []ProjectTag `json:"tags,omitempty" gorm:"foreignKey:ProjectID"`
|
||||
Featured bool `json:"featured" gorm:"default:false"`
|
||||
Views int `json:"views" gorm:"default:0"`
|
||||
Likes int `json:"likes" gorm:"default:0"`
|
||||
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
// ProjectTag represents tags for projects
|
||||
type ProjectTag struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
ProjectID uint `json:"project_id" gorm:"not null;index"`
|
||||
Tag string `json:"tag" gorm:"not null"`
|
||||
}
|
||||
|
||||
// SocialLink represents social media links
|
||||
type SocialLink struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
Platform string `json:"platform" gorm:"not null"` // github, linkedin, twitter, etc.
|
||||
URL string `json:"url" gorm:"not null"`
|
||||
Username string `json:"username"`
|
||||
Verified bool `json:"verified" gorm:"default:false"`
|
||||
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
// Follow represents user following relationships
|
||||
type Follow struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
FollowerID uint `json:"follower_id" gorm:"not null;index"`
|
||||
FollowingID uint `json:"following_id" gorm:"not null;index"`
|
||||
|
||||
Follower User `json:"follower,omitempty" gorm:"foreignKey:FollowerID"`
|
||||
Following User `json:"following,omitempty" gorm:"foreignKey:FollowingID"`
|
||||
}
|
||||
|
||||
// UserProfileStats represents aggregated user statistics
|
||||
type UserProfileStats struct {
|
||||
UserID uint `json:"user_id"`
|
||||
FollowersCount int `json:"followers_count"`
|
||||
FollowingCount int `json:"following_count"`
|
||||
PublicBookmarks int `json:"public_bookmarks"`
|
||||
PublicNotes int `json:"public_notes"`
|
||||
ProjectsCount int `json:"projects_count"`
|
||||
SkillsCount int `json:"skills_count"`
|
||||
TotalViews int `json:"total_views"`
|
||||
TotalLikes int `json:"total_likes"`
|
||||
ProfileCompletion float64 `json:"profile_completion"` // 0-100 percentage
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Team represents a collaborative workspace
|
||||
type Team struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
Avatar string `json:"avatar"`
|
||||
IsPublic bool `json:"is_public" gorm:"default:false"`
|
||||
IsActive bool `json:"is_active" gorm:"default:true"`
|
||||
|
||||
// Owner of the team
|
||||
OwnerID uint `json:"owner_id" gorm:"not null;index"`
|
||||
Owner User `json:"owner,omitempty" gorm:"foreignKey:OwnerID"`
|
||||
|
||||
// Team members and relationships
|
||||
Members []TeamMember `json:"members,omitempty" gorm:"foreignKey:TeamID"`
|
||||
Invitations []TeamInvitation `json:"invitations,omitempty" gorm:"foreignKey:TeamID"`
|
||||
Projects []TeamProject `json:"projects,omitempty" gorm:"foreignKey:TeamID"`
|
||||
Bookmarks []TeamBookmark `json:"bookmarks,omitempty" gorm:"foreignKey:TeamID"`
|
||||
Notes []TeamNote `json:"notes,omitempty" gorm:"foreignKey:TeamID"`
|
||||
Tasks []TeamTask `json:"tasks,omitempty" gorm:"foreignKey:TeamID"`
|
||||
Files []TeamFile `json:"files,omitempty" gorm:"foreignKey:TeamID"`
|
||||
}
|
||||
|
||||
// TeamMember represents a user's membership in a team
|
||||
type TeamMember struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
TeamID uint `json:"team_id" gorm:"not null;index"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
Role string `json:"role" gorm:"default:member"` // owner, admin, member, viewer
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
|
||||
Team Team `json:"team,omitempty" gorm:"foreignKey:TeamID"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
// TeamInvitation represents an invitation to join a team
|
||||
type TeamInvitation struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
TeamID uint `json:"team_id" gorm:"not null;index"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
Email string `json:"email" gorm:"not null"` // Email for non-registered users
|
||||
Role string `json:"role" gorm:"default:member"`
|
||||
Token string `json:"token" gorm:"uniqueIndex;not null"` // Invitation token
|
||||
Status string `json:"status" gorm:"default:pending"` // pending, accepted, declined, expired
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
InvitedBy uint `json:"invited_by" gorm:"not null"`
|
||||
|
||||
Team Team `json:"team,omitempty" gorm:"foreignKey:TeamID"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
Inviter User `json:"inviter,omitempty" gorm:"foreignKey:InvitedBy"`
|
||||
}
|
||||
|
||||
// TeamProject represents a project within a team
|
||||
type TeamProject struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
TeamID uint `json:"team_id" gorm:"not null;index"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status" gorm:"default:active"` // active, archived, completed
|
||||
RepositoryURL string `json:"repository_url"`
|
||||
LiveURL string `json:"live_url"`
|
||||
Tags []TeamProjectTag `json:"tags,omitempty" gorm:"foreignKey:ProjectID"`
|
||||
|
||||
Team Team `json:"team,omitempty" gorm:"foreignKey:TeamID"`
|
||||
}
|
||||
|
||||
// TeamProjectTag represents tags for team projects
|
||||
type TeamProjectTag struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
ProjectID uint `json:"project_id" gorm:"not null;index"`
|
||||
Tag string `json:"tag" gorm:"not null"`
|
||||
}
|
||||
|
||||
// TeamBookmark represents a bookmark shared within a team
|
||||
type TeamBookmark struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
TeamID uint `json:"team_id" gorm:"not null;index"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
BookmarkID uint `json:"bookmark_id" gorm:"not null;index"`
|
||||
|
||||
Team Team `json:"team,omitempty" gorm:"foreignKey:TeamID"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
Bookmark Bookmark `json:"bookmark,omitempty" gorm:"foreignKey:BookmarkID"`
|
||||
}
|
||||
|
||||
// TeamNote represents a note shared within a team
|
||||
type TeamNote struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
TeamID uint `json:"team_id" gorm:"not null;index"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
NoteID uint `json:"note_id" gorm:"not null;index"`
|
||||
|
||||
Team Team `json:"team,omitempty" gorm:"foreignKey:TeamID"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
Note Note `json:"note,omitempty" gorm:"foreignKey:NoteID"`
|
||||
}
|
||||
|
||||
// TeamTask represents a task within a team
|
||||
type TeamTask struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
TeamID uint `json:"team_id" gorm:"not null;index"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
TaskID uint `json:"task_id" gorm:"not null;index"`
|
||||
|
||||
Team Team `json:"team,omitempty" gorm:"foreignKey:TeamID"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
Task Task `json:"task,omitempty" gorm:"foreignKey:TaskID"`
|
||||
}
|
||||
|
||||
// TeamFile represents a file shared within a team
|
||||
type TeamFile struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
TeamID uint `json:"team_id" gorm:"not null;index"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
FileID uint `json:"file_id" gorm:"not null;index"`
|
||||
|
||||
Team Team `json:"team,omitempty" gorm:"foreignKey:TeamID"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
File File `json:"file,omitempty" gorm:"foreignKey:FileID"`
|
||||
}
|
||||
|
||||
// TeamActivity represents activity logs for team actions
|
||||
type TeamActivity struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
TeamID uint `json:"team_id" gorm:"not null;index"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
Action string `json:"action" gorm:"not null"` // created, updated, deleted, joined, left, etc.
|
||||
EntityType string `json:"entity_type" gorm:"not null"` // team, project, bookmark, note, task, file
|
||||
EntityID uint `json:"entity_id" gorm:"not null"`
|
||||
Details string `json:"details"` // JSON string with additional details
|
||||
|
||||
Team Team `json:"team,omitempty" gorm:"foreignKey:TeamID"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
// TeamStats represents aggregated team statistics
|
||||
type TeamStats struct {
|
||||
TeamID uint `json:"team_id"`
|
||||
MembersCount int64 `json:"members_count"`
|
||||
ProjectsCount int64 `json:"projects_count"`
|
||||
BookmarksCount int64 `json:"bookmarks_count"`
|
||||
NotesCount int64 `json:"notes_count"`
|
||||
TasksCount int64 `json:"tasks_count"`
|
||||
FilesCount int64 `json:"files_count"`
|
||||
RecentActivity int64 `json:"recent_activity"` // Activity in last 7 days
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TimeEntry represents a time tracking entry
|
||||
type TimeEntry struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// What is being tracked
|
||||
TaskID *uint `json:"task_id,omitempty"`
|
||||
Task *Task `json:"task,omitempty" gorm:"foreignKey:TaskID"`
|
||||
BookmarkID *uint `json:"bookmark_id,omitempty"`
|
||||
Bookmark *Bookmark `json:"bookmark,omitempty" gorm:"foreignKey:BookmarkID"`
|
||||
NoteID *uint `json:"note_id,omitempty"`
|
||||
Note *Note `json:"note,omitempty" gorm:"foreignKey:NoteID"`
|
||||
|
||||
// Time tracking data
|
||||
StartTime time.Time `json:"start_time" gorm:"not null"`
|
||||
EndTime *time.Time `json:"end_time"`
|
||||
Duration *int `json:"duration"` // Duration in seconds
|
||||
Description string `json:"description"`
|
||||
|
||||
// Organization and metadata
|
||||
Tags []Tag `json:"tags,omitempty" gorm:"many2many:time_entry_tags;"`
|
||||
Billable bool `json:"billable" gorm:"default:false"`
|
||||
|
||||
// Billing information
|
||||
HourlyRate *float64 `json:"hourly_rate"`
|
||||
|
||||
// Timer state
|
||||
IsRunning bool `json:"is_running" gorm:"default:false"`
|
||||
|
||||
// Additional metadata
|
||||
Source string `json:"source" gorm:"default:manual"` // manual, auto, pomodoro
|
||||
}
|
||||
|
||||
// BeforeCreate hook to set default values
|
||||
func (t *TimeEntry) BeforeCreate(tx *gorm.DB) error {
|
||||
if t.StartTime.IsZero() {
|
||||
t.StartTime = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate hook to calculate duration when end time is set
|
||||
func (t *TimeEntry) BeforeUpdate(tx *gorm.DB) error {
|
||||
if t.EndTime != nil && !t.EndTime.IsZero() {
|
||||
duration := int(t.EndTime.Sub(t.StartTime).Seconds())
|
||||
t.Duration = &duration
|
||||
t.IsRunning = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the timer and calculates duration
|
||||
func (t *TimeEntry) Stop() {
|
||||
now := time.Now()
|
||||
t.EndTime = &now
|
||||
duration := int(now.Sub(t.StartTime).Seconds())
|
||||
t.Duration = &duration
|
||||
t.IsRunning = false
|
||||
}
|
||||
|
||||
// GetDuration returns the duration in seconds
|
||||
func (t *TimeEntry) GetDuration() int {
|
||||
if t.Duration != nil {
|
||||
return *t.Duration
|
||||
}
|
||||
if t.IsRunning {
|
||||
return int(time.Since(t.StartTime).Seconds())
|
||||
}
|
||||
if t.EndTime != nil {
|
||||
return int(t.EndTime.Sub(t.StartTime).Seconds())
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetFormattedDuration returns a human-readable duration
|
||||
func (t *TimeEntry) GetFormattedDuration() string {
|
||||
duration := t.GetDuration()
|
||||
hours := duration / 3600
|
||||
minutes := (duration % 3600) / 60
|
||||
seconds := duration % 60
|
||||
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%dh %dm %ds", hours, minutes, seconds)
|
||||
} else if minutes > 0 {
|
||||
return fmt.Sprintf("%dm %ds", minutes, seconds)
|
||||
}
|
||||
return fmt.Sprintf("%ds", seconds)
|
||||
}
|
||||
+46
-9
@@ -17,15 +17,52 @@ type User struct {
|
||||
Username string `json:"username" gorm:"uniqueIndex;not null"`
|
||||
Password string `json:"-" gorm:"not null"` // Hashed password
|
||||
FullName string `json:"full_name"`
|
||||
|
||||
Role string `json:"role" gorm:"default:user"` // user, admin
|
||||
|
||||
// GitHub OAuth fields
|
||||
GitHubID int `json:"github_id" gorm:"uniqueIndex"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
Provider string `json:"provider" gorm:"default:email"` // email, github
|
||||
|
||||
// Preferences
|
||||
Theme string `json:"theme" gorm:"default:dark"`
|
||||
Language string `json:"language" gorm:"default:en"`
|
||||
Timezone string `json:"timezone" gorm:"default:UTC"`
|
||||
|
||||
Theme string `json:"theme" gorm:"default:dark"`
|
||||
Language string `json:"language" gorm:"default:en"`
|
||||
Timezone string `json:"timezone" gorm:"default:UTC"`
|
||||
|
||||
// Social Profile Features
|
||||
Bio string `json:"bio"`
|
||||
Location string `json:"location"`
|
||||
Website string `json:"website"`
|
||||
Company string `json:"company"`
|
||||
JobTitle string `json:"job_title"`
|
||||
Skills []Skill `json:"skills,omitempty" gorm:"foreignKey:UserID"`
|
||||
Projects []Project `json:"projects,omitempty" gorm:"foreignKey:UserID"`
|
||||
SocialLinks []SocialLink `json:"social_links,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Security & 2FA
|
||||
TOTPSecret string `json:"-" gorm:"column:totp_secret"` // Encrypted TOTP secret
|
||||
TOTPEnabled bool `json:"totp_enabled" gorm:"default:false"`
|
||||
BackupCodes string `json:"-" gorm:"column:backup_codes"` // Encrypted backup codes
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
LoginAttempts int `json:"login_attempts" gorm:"default:0"`
|
||||
LockedUntil *time.Time `json:"locked_until"`
|
||||
|
||||
// Privacy Settings
|
||||
ProfileVisibility string `json:"profile_visibility" gorm:"default:public"` // public, private, friends
|
||||
ShowEmail bool `json:"show_email" gorm:"default:false"`
|
||||
ShowActivity bool `json:"show_activity" gorm:"default:true"`
|
||||
AllowMessages bool `json:"allow_messages" gorm:"default:true"`
|
||||
|
||||
// Social Stats
|
||||
FollowersCount int `json:"followers_count" gorm:"default:0"`
|
||||
FollowingCount int `json:"following_count" gorm:"default:0"`
|
||||
PublicBookmarks int `json:"public_bookmarks" gorm:"default:0"`
|
||||
PublicNotes int `json:"public_notes" gorm:"default:0"`
|
||||
|
||||
// Relationships
|
||||
Bookmarks []Bookmark `json:"bookmarks,omitempty" gorm:"foreignKey:UserID"`
|
||||
Tasks []Task `json:"tasks,omitempty" gorm:"foreignKey:UserID"`
|
||||
Files []File `json:"files,omitempty" gorm:"foreignKey:UserID"`
|
||||
Notes []Note `json:"notes,omitempty" gorm:"foreignKey:UserID"`
|
||||
Bookmarks []Bookmark `json:"bookmarks,omitempty" gorm:"foreignKey:UserID"`
|
||||
Tasks []Task `json:"tasks,omitempty" gorm:"foreignKey:UserID"`
|
||||
Files []File `json:"files,omitempty" gorm:"foreignKey:UserID"`
|
||||
Notes []Note `json:"notes,omitempty" gorm:"foreignKey:UserID"`
|
||||
TimeEntries []TimeEntry `json:"time_entries,omitempty" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// VideoBookmark represents a bookmarked YouTube video
|
||||
type VideoBookmark struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
VideoID string `json:"video_id" gorm:"uniqueIndex;not null"` // YouTube video ID
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Channel string `json:"channel" gorm:"not null"`
|
||||
Thumbnail string `json:"thumbnail" gorm:"not null"`
|
||||
URL string `json:"url" gorm:"not null"`
|
||||
UserID uint `json:"user_id" gorm:"not null"` // Foreign key to User
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Tags string `json:"tags" gorm:"type:text"` // Comma-separated tags
|
||||
IsWatched bool `json:"is_watched" gorm:"default:false"`
|
||||
IsFavorite bool `json:"is_favorite" gorm:"default:false"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for VideoBookmark
|
||||
func (VideoBookmark) TableName() string {
|
||||
return "video_bookmarks"
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ScrapedContent represents content extracted from web pages
|
||||
type ScrapedContent struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Source information
|
||||
URL string `json:"url" gorm:"not null"`
|
||||
Domain string `json:"domain"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
PublishedDate *time.Time `json:"published_date"`
|
||||
LastScraped time.Time `json:"last_scraped"`
|
||||
|
||||
// Extracted content
|
||||
Content string `json:"content" gorm:"type:text"`
|
||||
Summary string `json:"summary" gorm:"type:text"`
|
||||
Keywords []string `json:"keywords" gorm:"serializer:json"`
|
||||
Tags []Tag `json:"tags,omitempty" gorm:"many2many:scraped_content_tags;"`
|
||||
Images []ScrapedImage `json:"images,omitempty" gorm:"foreignKey:ScrapedContentID"`
|
||||
Links []ScrapedLink `json:"links,omitempty" gorm:"foreignKey:ScrapedContentID"`
|
||||
Videos []ScrapedVideo `json:"videos,omitempty" gorm:"foreignKey:ScrapedContentID"`
|
||||
|
||||
// Content analysis
|
||||
ContentType string `json:"content_type"` // article, blog, news, tutorial, documentation
|
||||
WordCount int `json:"word_count"`
|
||||
ReadingTime int `json:"reading_time"` // estimated minutes
|
||||
Difficulty string `json:"difficulty"` // beginner, intermediate, advanced
|
||||
QualityScore float64 `json:"quality_score"` // 0-100
|
||||
|
||||
// Processing status
|
||||
Status string `json:"status" gorm:"default:pending"` // pending, processing, completed, failed
|
||||
ErrorMessage string `json:"error_message"`
|
||||
ProcessingLog string `json:"processing_log" gorm:"type:text"`
|
||||
|
||||
// Relationships
|
||||
BookmarkID *uint `json:"bookmark_id,omitempty"`
|
||||
Bookmark *Bookmark `json:"bookmark,omitempty" gorm:"foreignKey:BookmarkID"`
|
||||
NoteID *uint `json:"note_id,omitempty"`
|
||||
Note *Note `json:"note,omitempty" gorm:"foreignKey:NoteID"`
|
||||
}
|
||||
|
||||
// ScrapedImage represents images extracted from web pages
|
||||
type ScrapedImage struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
ScrapedContentID uint `json:"scraped_content_id" gorm:"not null;index"`
|
||||
ScrapedContent ScrapedContent `json:"scraped_content,omitempty" gorm:"foreignKey:ScrapedContentID"`
|
||||
|
||||
URL string `json:"url"`
|
||||
AltText string `json:"alt_text"`
|
||||
Title string `json:"title"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Format string `json:"format"` // jpg, png, gif, svg, webp
|
||||
Size int64 `json:"size"` // bytes in bytes
|
||||
IsMainImage bool `json:"is_main_image" gorm:"default:false"`
|
||||
LocalPath string `json:"local_path"` // if downloaded
|
||||
ThumbnailPath string `json:"thumbnail_path"` // if thumbnail generated
|
||||
}
|
||||
|
||||
// ScrapedLink represents links extracted from web pages
|
||||
type ScrapedLink struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
ScrapedContentID uint `json:"scraped_content_id" gorm:"not null;index"`
|
||||
ScrapedContent ScrapedContent `json:"scraped_content,omitempty" gorm:"foreignKey:ScrapedContentID"`
|
||||
|
||||
URL string `json:"url"`
|
||||
Text string `json:"text"`
|
||||
Title string `json:"title"`
|
||||
LinkType string `json:"link_type"` // internal, external, download, email
|
||||
IsNoFollow bool `json:"is_no_follow"`
|
||||
IsSponsored bool `json:"is_sponsored"`
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
|
||||
// ScrapedVideo represents videos extracted from web pages
|
||||
type ScrapedVideo struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
ScrapedContentID uint `json:"scraped_content_id" gorm:"not null;index"`
|
||||
ScrapedContent ScrapedContent `json:"scraped_content,omitempty" gorm:"foreignKey:ScrapedContentID"`
|
||||
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Duration string `json:"duration"` // in format "HH:MM:SS"
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Platform string `json:"platform"` // youtube, vimeo, twitch, etc.
|
||||
VideoID string `json:"video_id"` // platform-specific ID
|
||||
IsEmbeddable bool `json:"is_embeddable"`
|
||||
}
|
||||
|
||||
// ScrapingJob represents a web scraping job
|
||||
type ScrapingJob struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// Job details
|
||||
URL string `json:"url" gorm:"not null"`
|
||||
JobType string `json:"job_type" gorm:"default:full_scrape"` // full_scrape, content_only, images_only, links_only
|
||||
Priority string `json:"priority" gorm:"default:normal"` // low, normal, high, urgent
|
||||
Status string `json:"status" gorm:"default:pending"` // pending, processing, completed, failed, cancelled
|
||||
|
||||
// Processing options
|
||||
ExtractImages bool `json:"extract_images" gorm:"default:true"`
|
||||
ExtractLinks bool `json:"extract_links" gorm:"default:true"`
|
||||
ExtractVideos bool `json:"extract_videos" gorm:"default:true"`
|
||||
GenerateSummary bool `json:"generate_summary" gorm:"default:true"`
|
||||
DownloadImages bool `json:"download_images" gorm:"default:false"`
|
||||
ExtractMetadata bool `json:"extract_metadata" gorm:"default:true"`
|
||||
|
||||
// Timing and results
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
Progress float64 `json:"progress" gorm:"default:0"` // 0-100
|
||||
ErrorMessage string `json:"error_message"`
|
||||
|
||||
// Relationships
|
||||
ScrapedContentID *uint `json:"scraped_content_id,omitempty"`
|
||||
ScrapedContent *ScrapedContent `json:"scraped_content,omitempty" gorm:"foreignKey:ScrapedContentID"`
|
||||
}
|
||||
|
||||
// BeforeCreate hooks
|
||||
func (s *ScrapedContent) BeforeCreate(tx *gorm.DB) error {
|
||||
if s.Status == "" {
|
||||
s.Status = "pending"
|
||||
}
|
||||
if s.LastScraped.IsZero() {
|
||||
s.LastScraped = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *ScrapingJob) BeforeCreate(tx *gorm.DB) error {
|
||||
if j.Status == "" {
|
||||
j.Status = "pending"
|
||||
}
|
||||
if j.Priority == "" {
|
||||
j.Priority = "normal"
|
||||
}
|
||||
if j.JobType == "" {
|
||||
j.JobType = "full_scrape"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// YouTubeChannelCache represents cached YouTube channel data
|
||||
type YouTubeChannelCache struct {
|
||||
ID int `json:"id" gorm:"primaryKey"`
|
||||
ChannelID string `json:"channel_id" gorm:"uniqueIndex"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
ChannelURL string `json:"channel_url"`
|
||||
Videos string `json:"videos" gorm:"type:text"` // JSON array of videos
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for YouTubeChannelCache
|
||||
func (YouTubeChannelCache) TableName() string {
|
||||
return "youtube_channel_cache"
|
||||
}
|
||||
|
||||
// IsExpired checks if the cache is older than 2 hours
|
||||
func (y *YouTubeChannelCache) IsExpired() bool {
|
||||
return time.Since(y.LastUpdated) > 2*time.Hour
|
||||
}
|
||||
+1503
-43
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,553 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AIRecommendationService provides AI-powered recommendations
|
||||
type AIRecommendationService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAIRecommendationService creates a new AI recommendation service
|
||||
func NewAIRecommendationService(db *gorm.DB) *AIRecommendationService {
|
||||
return &AIRecommendationService{db: db}
|
||||
}
|
||||
|
||||
// RecommendationRequest represents a request for recommendations
|
||||
type RecommendationRequest struct {
|
||||
UserID uint `json:"user_id"`
|
||||
RecommendationType string `json:"recommendation_type"` // content, task, learning, connection
|
||||
Limit int `json:"limit"` // Max recommendations to return
|
||||
MinConfidence float64 `json:"min_confidence"` // Minimum confidence threshold
|
||||
IncludeDismissed bool `json:"include_dismissed"` // Include previously dismissed items
|
||||
Context string `json:"context"` // Current user context
|
||||
}
|
||||
|
||||
// RecommendationScore represents a scored recommendation
|
||||
type RecommendationScore struct {
|
||||
Recommendation models.AIRecommendation
|
||||
Score float64
|
||||
Reason string
|
||||
}
|
||||
|
||||
// GetRecommendations generates personalized recommendations for a user
|
||||
func (s *AIRecommendationService) GetRecommendations(req RecommendationRequest) ([]models.AIRecommendation, error) {
|
||||
// Get user preferences
|
||||
var prefs models.UserPreference
|
||||
if err := s.db.Where("user_id = ?", req.UserID).First(&prefs).Error; err != nil {
|
||||
// Create default preferences if not found
|
||||
prefs = models.UserPreference{
|
||||
UserID: req.UserID,
|
||||
EnableRecommendations: true,
|
||||
MinConfidenceThreshold: 0.6,
|
||||
MaxRecommendationsPerDay: 5,
|
||||
MaxAgeHours: 168,
|
||||
}
|
||||
s.db.Create(&prefs)
|
||||
}
|
||||
|
||||
if !prefs.EnableRecommendations {
|
||||
return []models.AIRecommendation{}, nil
|
||||
}
|
||||
|
||||
// Check daily limit
|
||||
today := time.Now().Format("2006-01-02")
|
||||
var todayCount int64
|
||||
s.db.Model(&models.AIRecommendation{}).
|
||||
Where("user_id = ? AND DATE(created_at) = ?", req.UserID, today).
|
||||
Count(&todayCount)
|
||||
|
||||
if int(todayCount) >= prefs.MaxRecommendationsPerDay {
|
||||
return []models.AIRecommendation{}, nil
|
||||
}
|
||||
|
||||
// Generate recommendations based on type
|
||||
var scoredRecommendations []RecommendationScore
|
||||
|
||||
switch req.RecommendationType {
|
||||
case "content":
|
||||
scoredRecommendations = s.generateContentRecommendations(req.UserID, &prefs)
|
||||
case "task":
|
||||
scoredRecommendations = s.generateTaskRecommendations(req.UserID, &prefs)
|
||||
case "learning":
|
||||
scoredRecommendations = s.generateLearningRecommendations(req.UserID, &prefs)
|
||||
case "connection":
|
||||
scoredRecommendations = s.generateConnectionRecommendations(req.UserID, &prefs)
|
||||
default:
|
||||
// Generate mixed recommendations
|
||||
scoredRecommendations = s.generateMixedRecommendations(req.UserID, &prefs)
|
||||
}
|
||||
|
||||
// Filter by confidence and dismissed status
|
||||
minConf := req.MinConfidence
|
||||
if minConf == 0 {
|
||||
minConf = prefs.MinConfidenceThreshold
|
||||
}
|
||||
|
||||
var filtered []RecommendationScore
|
||||
for _, rec := range scoredRecommendations {
|
||||
if rec.Score >= minConf && (req.IncludeDismissed || !rec.Recommendation.Dismissed) {
|
||||
// Check if not expired
|
||||
if rec.Recommendation.ExpiresAt == nil || rec.Recommendation.ExpiresAt.After(time.Now()) {
|
||||
filtered = append(filtered, rec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
return filtered[i].Score > filtered[j].Score
|
||||
})
|
||||
|
||||
// Apply limit
|
||||
limit := req.Limit
|
||||
if limit == 0 {
|
||||
limit = 5
|
||||
}
|
||||
if limit > len(filtered) {
|
||||
limit = len(filtered)
|
||||
}
|
||||
|
||||
// Save recommendations to database
|
||||
var recommendations []models.AIRecommendation
|
||||
for i := 0; i < limit; i++ {
|
||||
rec := filtered[i].Recommendation
|
||||
rec.Confidence = filtered[i].Score
|
||||
|
||||
// Check if already exists
|
||||
var existing models.AIRecommendation
|
||||
err := s.db.Where("user_id = ? AND content_type = ? AND content_id = ?",
|
||||
rec.UserID, rec.ContentType, rec.ContentID).First(&existing).Error
|
||||
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Create new recommendation
|
||||
if err := s.db.Create(&rec).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
recommendations = append(recommendations, rec)
|
||||
} else {
|
||||
// Update existing recommendation
|
||||
existing.Confidence = rec.Confidence
|
||||
existing.Reasoning = filtered[i].Reason
|
||||
existing.UpdatedAt = time.Now()
|
||||
s.db.Save(&existing)
|
||||
recommendations = append(recommendations, existing)
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations, nil
|
||||
}
|
||||
|
||||
// generateContentRecommendations generates content-based recommendations
|
||||
func (s *AIRecommendationService) generateContentRecommendations(userID uint, prefs *models.UserPreference) []RecommendationScore {
|
||||
var recommendations []RecommendationScore
|
||||
|
||||
// Get user's recent activity and interests
|
||||
userTags := s.getUserInterests(userID)
|
||||
userCategories := s.getUserCategories(userID)
|
||||
|
||||
// Find similar content from other users
|
||||
var similarContent []struct {
|
||||
ContentType string
|
||||
ContentID uint
|
||||
Title string
|
||||
URL string
|
||||
Preview string
|
||||
Author string
|
||||
Tags string
|
||||
Score float64
|
||||
}
|
||||
|
||||
// Query for popular content among similar users
|
||||
query := `
|
||||
SELECT
|
||||
b.content_type,
|
||||
b.content_id,
|
||||
b.title,
|
||||
b.url,
|
||||
SUBSTRING(b.content, 1, 200) as preview,
|
||||
u.full_name as author,
|
||||
b.tags,
|
||||
COUNT(*) as interaction_count
|
||||
FROM bookmarks b
|
||||
INNER JOIN users u ON b.user_id = u.id
|
||||
WHERE b.user_id != ?
|
||||
AND b.created_at > ?
|
||||
AND (b.tags ?| array[?] OR b.content_type = ANY(?))
|
||||
GROUP BY b.content_type, b.content_id, b.title, b.url, b.content, u.full_name, b.tags
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY interaction_count DESC
|
||||
LIMIT 20
|
||||
`
|
||||
|
||||
// This is a simplified version - in practice you'd use more sophisticated similarity algorithms
|
||||
s.db.Raw(query, userID, time.Now().AddDate(0, -3, 0), userTags, prefs.PreferredContentTypes).Scan(&similarContent)
|
||||
|
||||
for _, content := range similarContent {
|
||||
score := s.calculateContentScore(content, userTags, userCategories, prefs)
|
||||
|
||||
expiresAt := time.Now().Add(time.Hour * 24 * 7)
|
||||
|
||||
recommendation := models.AIRecommendation{
|
||||
UserID: userID,
|
||||
RecommendationType: "content",
|
||||
ContentType: content.ContentType,
|
||||
ContentID: &content.ContentID,
|
||||
Title: content.Title,
|
||||
Description: fmt.Sprintf("Recommended based on your interests in %s", strings.Join(userTags, ", ")),
|
||||
ContentTitle: content.Title,
|
||||
ContentURL: content.URL,
|
||||
ContentPreview: content.Preview,
|
||||
AuthorName: content.Author,
|
||||
Tags: content.Tags,
|
||||
Priority: s.getPriorityFromScore(score),
|
||||
ExpiresAt: &expiresAt,
|
||||
SourceModel: "collaborative_filtering_v1",
|
||||
}
|
||||
|
||||
recommendations = append(recommendations, RecommendationScore{
|
||||
Recommendation: recommendation,
|
||||
Score: score,
|
||||
Reason: fmt.Sprintf("Similar users with interests in %s also liked this", strings.Join(userTags[:2], ", ")),
|
||||
})
|
||||
}
|
||||
|
||||
return recommendations
|
||||
}
|
||||
|
||||
// generateTaskRecommendations generates task-based recommendations
|
||||
func (s *AIRecommendationService) generateTaskRecommendations(userID uint, prefs *models.UserPreference) []RecommendationScore {
|
||||
var recommendations []RecommendationScore
|
||||
|
||||
// Get user's task patterns and upcoming deadlines
|
||||
var userTasks []models.Task
|
||||
s.db.Where("user_id = ? AND status != 'completed'", userID).Find(&userTasks)
|
||||
|
||||
// Get calendar events for context
|
||||
var upcomingEvents []models.CalendarEvent
|
||||
s.db.Where("user_id = ? AND start_time BETWEEN ? AND ?",
|
||||
userID, time.Now(), time.Now().AddDate(0, 0, 7)).Find(&upcomingEvents)
|
||||
|
||||
// Analyze patterns and suggest tasks
|
||||
for _, task := range userTasks {
|
||||
if string(task.Priority) == "high" && task.DueDate != nil && task.DueDate.Before(time.Now().AddDate(0, 0, 3)) {
|
||||
// High priority task due soon
|
||||
score := 0.9
|
||||
|
||||
// Convert tags to string
|
||||
var tagStr string
|
||||
for i, tag := range task.Tags {
|
||||
if i > 0 {
|
||||
tagStr += ","
|
||||
}
|
||||
tagStr += tag.Name
|
||||
}
|
||||
|
||||
expiresAt := task.DueDate
|
||||
|
||||
recommendation := models.AIRecommendation{
|
||||
UserID: userID,
|
||||
RecommendationType: "task",
|
||||
ContentType: "task",
|
||||
ContentID: &task.ID,
|
||||
Title: fmt.Sprintf("Focus on: %s", task.Title),
|
||||
Description: fmt.Sprintf("This high-priority task is due on %s", task.DueDate.Format("Jan 2")),
|
||||
ContentTitle: task.Title,
|
||||
ContentPreview: task.Description,
|
||||
Tags: tagStr,
|
||||
Priority: "high",
|
||||
Confidence: score,
|
||||
ExpiresAt: expiresAt,
|
||||
SourceModel: "deadline_priority_v1",
|
||||
}
|
||||
|
||||
recommendations = append(recommendations, RecommendationScore{
|
||||
Recommendation: recommendation,
|
||||
Score: score,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations
|
||||
}
|
||||
|
||||
// generateLearningRecommendations generates learning-based recommendations
|
||||
func (s *AIRecommendationService) generateLearningRecommendations(userID uint, prefs *models.UserPreference) []RecommendationScore {
|
||||
var recommendations []RecommendationScore
|
||||
|
||||
// Get user's learning progress and interests
|
||||
var enrollments []models.Enrollment
|
||||
s.db.Preload("Course").Preload("LearningPath").Where("user_id = ?", userID).Find(&enrollments)
|
||||
|
||||
var completedCategories []string
|
||||
var inProgressCategories []string
|
||||
for _, enrollment := range enrollments {
|
||||
if enrollment.Progress >= 100 {
|
||||
if enrollment.CourseID != nil && enrollment.Course != nil {
|
||||
completedCategories = append(completedCategories, enrollment.Course.Category)
|
||||
} else if enrollment.LearningPathID != 0 {
|
||||
completedCategories = append(completedCategories, enrollment.LearningPath.Category)
|
||||
}
|
||||
} else {
|
||||
if enrollment.CourseID != nil && enrollment.Course != nil {
|
||||
inProgressCategories = append(inProgressCategories, enrollment.Course.Category)
|
||||
} else if enrollment.LearningPathID != 0 {
|
||||
inProgressCategories = append(inProgressCategories, enrollment.LearningPath.Category)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recommend next courses based on completed ones
|
||||
for _, category := range completedCategories {
|
||||
var nextCourses []models.Course
|
||||
s.db.Where("category = ? AND level != ?", category, "beginner").Limit(3).Find(&nextCourses)
|
||||
|
||||
for _, course := range nextCourses {
|
||||
score := 0.8
|
||||
|
||||
// Convert topics to tags string
|
||||
var tagsStr string
|
||||
for i, topic := range course.Topics {
|
||||
if i > 0 {
|
||||
tagsStr += ","
|
||||
}
|
||||
tagsStr += topic
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(time.Hour * 24 * 14)
|
||||
|
||||
recommendation := models.AIRecommendation{
|
||||
UserID: userID,
|
||||
RecommendationType: "learning",
|
||||
ContentType: "course",
|
||||
ContentID: &course.ID,
|
||||
Title: fmt.Sprintf("Continue learning: %s", course.Title),
|
||||
Description: fmt.Sprintf("Based on your completion of %s courses", category),
|
||||
ContentTitle: course.Title,
|
||||
ContentPreview: course.Description,
|
||||
Tags: tagsStr,
|
||||
Priority: "medium",
|
||||
Confidence: score,
|
||||
ExpiresAt: &expiresAt,
|
||||
SourceModel: "learning_path_v1",
|
||||
}
|
||||
|
||||
recommendations = append(recommendations, RecommendationScore{
|
||||
Recommendation: recommendation,
|
||||
Score: score,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations
|
||||
}
|
||||
|
||||
// generateConnectionRecommendations generates user connection recommendations
|
||||
func (s *AIRecommendationService) generateConnectionRecommendations(userID uint, prefs *models.UserPreference) []RecommendationScore {
|
||||
var recommendations []RecommendationScore
|
||||
|
||||
if !prefs.ConnectionRecommendations {
|
||||
return recommendations
|
||||
}
|
||||
|
||||
// Get user's skills and interests
|
||||
var user models.User
|
||||
s.db.Preload("Skills").Where("id = ?", userID).First(&user)
|
||||
|
||||
// Find similar users
|
||||
var similarUsers []models.User
|
||||
s.db.Where("id != ? AND (skills ?| array[?] OR location = ?)",
|
||||
userID, s.getSkillNames(user.Skills), user.Location).Limit(5).Find(&similarUsers)
|
||||
|
||||
for _, similarUser := range similarUsers {
|
||||
score := s.calculateUserSimilarity(&user, &similarUser)
|
||||
|
||||
if score > 0.6 {
|
||||
expiresAt := time.Now().Add(time.Hour * 24 * 30)
|
||||
|
||||
recommendation := models.AIRecommendation{
|
||||
UserID: userID,
|
||||
RecommendationType: "connection",
|
||||
ContentType: "user",
|
||||
ContentID: &similarUser.ID,
|
||||
Title: fmt.Sprintf("Connect with %s", similarUser.FullName),
|
||||
Description: fmt.Sprintf("Similar interests in %s", s.getSharedInterests(&user, &similarUser)),
|
||||
ContentTitle: similarUser.FullName,
|
||||
ContentPreview: similarUser.Bio,
|
||||
AuthorName: similarUser.JobTitle,
|
||||
Priority: "low",
|
||||
Confidence: score,
|
||||
ExpiresAt: &expiresAt,
|
||||
SourceModel: "user_similarity_v1",
|
||||
}
|
||||
|
||||
recommendations = append(recommendations, RecommendationScore{
|
||||
Recommendation: recommendation,
|
||||
Score: score,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations
|
||||
}
|
||||
|
||||
// generateMixedRecommendations generates a mix of all recommendation types
|
||||
func (s *AIRecommendationService) generateMixedRecommendations(userID uint, prefs *models.UserPreference) []RecommendationScore {
|
||||
var allRecommendations []RecommendationScore
|
||||
|
||||
// Get recommendations from all types
|
||||
contentRecs := s.generateContentRecommendations(userID, prefs)
|
||||
taskRecs := s.generateTaskRecommendations(userID, prefs)
|
||||
learningRecs := s.generateLearningRecommendations(userID, prefs)
|
||||
connectionRecs := s.generateConnectionRecommendations(userID, prefs)
|
||||
|
||||
allRecommendations = append(allRecommendations, contentRecs...)
|
||||
allRecommendations = append(allRecommendations, taskRecs...)
|
||||
allRecommendations = append(allRecommendations, learningRecs...)
|
||||
allRecommendations = append(allRecommendations, connectionRecs...)
|
||||
|
||||
// Ensure diverse mix by limiting each type
|
||||
maxPerType := 2
|
||||
typeCounts := make(map[string]int)
|
||||
var mixedRecs []RecommendationScore
|
||||
|
||||
for _, rec := range allRecommendations {
|
||||
if typeCounts[rec.Recommendation.ContentType] < maxPerType {
|
||||
mixedRecs = append(mixedRecs, rec)
|
||||
typeCounts[rec.Recommendation.ContentType]++
|
||||
}
|
||||
}
|
||||
|
||||
return mixedRecs
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func (s *AIRecommendationService) getUserInterests(userID uint) []string {
|
||||
var tags []string
|
||||
s.db.Model(&models.Bookmark{}).
|
||||
Select("DISTINCT unnest(string_to_array(tags, ',')) as tag").
|
||||
Where("user_id = ?", userID).
|
||||
Pluck("tag", &tags)
|
||||
return tags
|
||||
}
|
||||
|
||||
func (s *AIRecommendationService) getUserCategories(userID uint) []string {
|
||||
var categories []string
|
||||
s.db.Model(&models.Course{}).
|
||||
Select("DISTINCT category").
|
||||
Joins("JOIN enrollments ON courses.id = enrollments.course_id").
|
||||
Where("enrollments.user_id = ?", userID).
|
||||
Pluck("category", &categories)
|
||||
return categories
|
||||
}
|
||||
|
||||
func (s *AIRecommendationService) calculateContentScore(content interface{}, userTags, userCategories []string, prefs *models.UserPreference) float64 {
|
||||
// Simplified scoring algorithm
|
||||
score := 0.5 // Base score
|
||||
|
||||
// Add points for tag matches
|
||||
// In practice, this would be more sophisticated
|
||||
score += float64(len(userTags)) * 0.1
|
||||
|
||||
// Ensure score is within bounds
|
||||
if score > 1.0 {
|
||||
score = 1.0
|
||||
}
|
||||
if score < 0.0 {
|
||||
score = 0.0
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func (s *AIRecommendationService) getPriorityFromScore(score float64) string {
|
||||
if score >= 0.8 {
|
||||
return "high"
|
||||
} else if score >= 0.6 {
|
||||
return "medium"
|
||||
}
|
||||
return "low"
|
||||
}
|
||||
|
||||
func (s *AIRecommendationService) getSkillNames(skills []models.Skill) []string {
|
||||
var names []string
|
||||
for _, skill := range skills {
|
||||
names = append(names, skill.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (s *AIRecommendationService) calculateUserSimilarity(user1, user2 *models.User) float64 {
|
||||
// Simplified similarity calculation
|
||||
score := 0.0
|
||||
|
||||
// Location match
|
||||
if user1.Location == user2.Location && user1.Location != "" {
|
||||
score += 0.3
|
||||
}
|
||||
|
||||
// Skills overlap (simplified)
|
||||
if len(user1.Skills) > 0 && len(user2.Skills) > 0 {
|
||||
score += 0.4
|
||||
}
|
||||
|
||||
// Random factor for demo
|
||||
score += 0.2
|
||||
|
||||
if score > 1.0 {
|
||||
score = 1.0
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func (s *AIRecommendationService) getSharedInterests(user1, user2 *models.User) string {
|
||||
// Simplified - in practice would analyze actual shared interests
|
||||
interests := []string{"technology", "productivity", "learning"}
|
||||
if len(interests) > 2 {
|
||||
return strings.Join(interests[:2], ", ")
|
||||
}
|
||||
return strings.Join(interests, ", ")
|
||||
}
|
||||
|
||||
// RecordInteraction records user interaction with recommendations
|
||||
func (s *AIRecommendationService) RecordInteraction(userID, recommendationID uint, interactionType, context string) error {
|
||||
interaction := models.RecommendationInteraction{
|
||||
UserID: userID,
|
||||
RecommendationID: recommendationID,
|
||||
InteractionType: interactionType,
|
||||
Context: context,
|
||||
SessionID: fmt.Sprintf("session_%d_%d", userID, time.Now().Unix()),
|
||||
DeviceType: "web", // Would be detected from request
|
||||
}
|
||||
|
||||
// Update recommendation based on interaction
|
||||
var recommendation models.AIRecommendation
|
||||
if err := s.db.First(&recommendation, recommendationID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch interactionType {
|
||||
case "click":
|
||||
recommendation.Clicked = true
|
||||
now := time.Now()
|
||||
recommendation.ClickedAt = &now
|
||||
case "dismiss":
|
||||
recommendation.Dismissed = true
|
||||
now := time.Now()
|
||||
recommendation.DismissedAt = &now
|
||||
case "feedback":
|
||||
// Additional feedback handling would go here
|
||||
}
|
||||
|
||||
s.db.Save(&recommendation)
|
||||
return s.db.Create(&interaction).Error
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AIProvider represents different AI providers
|
||||
type AIProvider string
|
||||
|
||||
const (
|
||||
ProviderMistral AIProvider = "mistral"
|
||||
ProviderLongCat AIProvider = "longcat"
|
||||
ProviderGrok AIProvider = "grok"
|
||||
ProviderDeepSeek AIProvider = "deepseek"
|
||||
ProviderOllama AIProvider = "ollama"
|
||||
ProviderOpenRouter AIProvider = "openrouter"
|
||||
)
|
||||
|
||||
// AIRequest represents a generic AI request
|
||||
type AIRequest struct {
|
||||
Messages []Message `json:"messages"`
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
ModelType string `json:"model_type,omitempty"` // "standard", "thinking", "upgraded_thinking"
|
||||
}
|
||||
|
||||
// Message represents a chat message
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
}
|
||||
|
||||
// AIResponse represents a generic AI response
|
||||
type AIResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []Choice `json:"choices"`
|
||||
Usage Usage `json:"usage"`
|
||||
}
|
||||
|
||||
// Choice represents a choice in AI response
|
||||
type Choice struct {
|
||||
Index int `json:"index"`
|
||||
Message Message `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
// Usage represents token usage
|
||||
type Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// AIService handles multiple AI providers
|
||||
type AIService struct {
|
||||
provider AIProvider
|
||||
}
|
||||
|
||||
// NewAIService creates a new AI service with the specified provider
|
||||
func NewAIService(provider AIProvider) *AIService {
|
||||
return &AIService{provider: provider}
|
||||
}
|
||||
|
||||
// GetAvailableProviders returns available AI providers
|
||||
func GetAvailableProviders() []AIProvider {
|
||||
// Return all known providers so the frontend can show them in settings
|
||||
// regardless of current environment configuration. Environment flags
|
||||
// and API keys still control whether requests actually succeed.
|
||||
return []AIProvider{
|
||||
ProviderMistral,
|
||||
ProviderLongCat,
|
||||
ProviderGrok,
|
||||
ProviderDeepSeek,
|
||||
ProviderOllama,
|
||||
ProviderOpenRouter,
|
||||
}
|
||||
}
|
||||
|
||||
// ChatCompletion sends a chat completion request to the configured provider
|
||||
func (s *AIService) ChatCompletion(req AIRequest) (*AIResponse, error) {
|
||||
switch s.provider {
|
||||
case ProviderMistral:
|
||||
return s.callMistral(req)
|
||||
case ProviderLongCat:
|
||||
return s.callLongCat(req)
|
||||
case ProviderGrok:
|
||||
return s.callGrok(req)
|
||||
case ProviderDeepSeek:
|
||||
return s.callDeepSeek(req)
|
||||
case ProviderOllama:
|
||||
return s.callOllama(req)
|
||||
case ProviderOpenRouter:
|
||||
return s.callOpenRouter(req)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported AI provider: %s", s.provider)
|
||||
}
|
||||
}
|
||||
|
||||
// ChatCompletionWithThinking sends a chat completion request with thinking model
|
||||
func (s *AIService) ChatCompletionWithThinking(req AIRequest) (*AIResponse, error) {
|
||||
// Override model with thinking model
|
||||
thinkingModel := s.getThinkingModel()
|
||||
if thinkingModel != "" {
|
||||
req.Model = thinkingModel
|
||||
}
|
||||
|
||||
return s.ChatCompletion(req)
|
||||
}
|
||||
|
||||
// ChatCompletionWithUpgradedThinking sends a chat completion request with upgraded thinking model (LongCat only)
|
||||
func (s *AIService) ChatCompletionWithUpgradedThinking(req AIRequest) (*AIResponse, error) {
|
||||
if s.provider != ProviderLongCat {
|
||||
return nil, fmt.Errorf("upgraded thinking model only available for LongCat provider")
|
||||
}
|
||||
|
||||
// Override model with upgraded thinking model
|
||||
upgradedModel := os.Getenv("LONGCAT_MODEL_THINKING_UPGRADED")
|
||||
if upgradedModel != "" {
|
||||
req.Model = upgradedModel
|
||||
}
|
||||
|
||||
return s.ChatCompletion(req)
|
||||
}
|
||||
|
||||
// ParseThinkingResponse extracts the actual content from thinking model responses
|
||||
func ParseThinkingResponse(resp *AIResponse, provider AIProvider, modelType string) string {
|
||||
if provider == ProviderLongCat {
|
||||
// Handle LongCat thinking models
|
||||
if resp.Choices[0].Message.Content != "" {
|
||||
content := resp.Choices[0].Message.Content
|
||||
|
||||
// For LongCat-Flash-Thinking, remove thinking tags
|
||||
if strings.Contains(content, "<longcat_think>") {
|
||||
// Extract content after thinking tags
|
||||
parts := strings.Split(content, "</longcat_think>")
|
||||
if len(parts) > 1 {
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
// If no closing tag, try to extract after the thinking content
|
||||
lines := strings.Split(content, "\n")
|
||||
for i, line := range lines {
|
||||
if strings.Contains(line, "</longcat_think>") {
|
||||
return strings.TrimSpace(strings.Join(lines[i+1:], "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return content
|
||||
} else if resp.Choices[0].Message.ReasoningContent != "" {
|
||||
// For LongCat-Flash-Thinking-2601, check if there's actual content
|
||||
// This model puts reasoning in reasoning_content and final answer in content
|
||||
// If content is null, we might need to extract from reasoning or return the reasoning itself
|
||||
return resp.Choices[0].Message.ReasoningContent
|
||||
}
|
||||
}
|
||||
|
||||
// For Grok, DeepSeek, Mistral and other providers, or if no special handling needed
|
||||
return resp.Choices[0].Message.Content
|
||||
}
|
||||
|
||||
// getThinkingModel returns the appropriate thinking model for the provider
|
||||
func (s *AIService) getThinkingModel() string {
|
||||
switch s.provider {
|
||||
case ProviderMistral:
|
||||
return os.Getenv("MISTRAL_MODEL_THINKING")
|
||||
case ProviderLongCat:
|
||||
return os.Getenv("LONGCAT_MODEL_THINKING")
|
||||
case ProviderGrok:
|
||||
return os.Getenv("GROK_MODEL_THINKING")
|
||||
case ProviderDeepSeek:
|
||||
return os.Getenv("DEEPSEEK_MODEL_THINKING")
|
||||
case ProviderOllama:
|
||||
return os.Getenv("OLLAMA_MODEL_THINKING")
|
||||
case ProviderOpenRouter:
|
||||
return os.Getenv("OPENROUTER_MODEL_THINKING")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// callOpenRouter calls the OpenRouter API (OpenAI-compatible)
|
||||
func (s *AIService) callOpenRouter(req AIRequest) (*AIResponse, error) {
|
||||
apiKey := os.Getenv("OPENROUTER_API_KEY")
|
||||
baseURL := os.Getenv("OPENROUTER_BASE_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://openrouter.ai/api"
|
||||
}
|
||||
|
||||
model := os.Getenv("OPENROUTER_MODEL")
|
||||
if model == "" {
|
||||
model = "openrouter/auto"
|
||||
}
|
||||
|
||||
if req.Model == "" {
|
||||
req.Model = model
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", baseURL+"/v1/chat/completions", strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
if apiKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("OpenRouter API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var orResp AIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&orResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &orResp, nil
|
||||
}
|
||||
|
||||
// callMistral calls Mistral AI API
|
||||
func (s *AIService) callMistral(req AIRequest) (*AIResponse, error) {
|
||||
apiKey := os.Getenv("MISTRAL_API_KEY")
|
||||
baseURL := "https://api.mistral.ai/v1"
|
||||
model := os.Getenv("MISTRAL_MODEL")
|
||||
if model == "" {
|
||||
model = "mistral-small-latest"
|
||||
}
|
||||
|
||||
if req.Model == "" {
|
||||
req.Model = model
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", baseURL+"/chat/completions", strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Mistral API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var mistralResp AIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&mistralResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &mistralResp, nil
|
||||
}
|
||||
|
||||
// callLongCat calls LongCat AI API
|
||||
func (s *AIService) callLongCat(req AIRequest) (*AIResponse, error) {
|
||||
apiKey := os.Getenv("LONGCAT_API_KEY")
|
||||
|
||||
// Determine format and endpoint
|
||||
format := os.Getenv("LONGCAT_FORMAT")
|
||||
if format == "" {
|
||||
format = "openai" // Default to OpenAI format
|
||||
}
|
||||
|
||||
var baseURL string
|
||||
switch format {
|
||||
case "openai":
|
||||
baseURL = "https://api.longcat.chat/openai"
|
||||
case "anthropic":
|
||||
baseURL = "https://api.longcat.chat/anthropic"
|
||||
default:
|
||||
baseURL = "https://api.longcat.chat/openai"
|
||||
}
|
||||
|
||||
model := os.Getenv("LONGCAT_MODEL")
|
||||
if model == "" {
|
||||
model = "LongCat-Flash-Chat"
|
||||
}
|
||||
|
||||
if req.Model == "" {
|
||||
req.Model = model
|
||||
}
|
||||
|
||||
var jsonBody []byte
|
||||
var httpReq *http.Request
|
||||
var err error
|
||||
|
||||
if format == "anthropic" {
|
||||
// Convert to Anthropic format
|
||||
anthropicReq := map[string]interface{}{
|
||||
"model": req.Model,
|
||||
"max_tokens": req.MaxTokens,
|
||||
"messages": req.Messages,
|
||||
}
|
||||
if req.Temperature > 0 {
|
||||
anthropicReq["temperature"] = req.Temperature
|
||||
}
|
||||
|
||||
jsonBody, err = json.Marshal(anthropicReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err = http.NewRequest("POST", baseURL+"/v1/messages", strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
httpReq.Header.Set("anthropic-version", "2023-06-01")
|
||||
} else {
|
||||
// OpenAI format
|
||||
jsonBody, err = json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err = http.NewRequest("POST", baseURL+"/v1/chat/completions", strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("LongCat API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var longcatResp AIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&longcatResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &longcatResp, nil
|
||||
}
|
||||
|
||||
// callGrok calls Grok AI API
|
||||
func (s *AIService) callGrok(req AIRequest) (*AIResponse, error) {
|
||||
apiKey := os.Getenv("GROK_API_KEY")
|
||||
baseURL := os.Getenv("GROK_BASE_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.x.ai/v1"
|
||||
}
|
||||
|
||||
model := os.Getenv("GROK_MODEL")
|
||||
if model == "" {
|
||||
model = "grok-beta"
|
||||
}
|
||||
|
||||
if req.Model == "" {
|
||||
req.Model = model
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", baseURL+"/chat/completions", strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Grok API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var grokResp AIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&grokResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &grokResp, nil
|
||||
}
|
||||
|
||||
// callDeepSeek calls DeepSeek API
|
||||
func (s *AIService) callDeepSeek(req AIRequest) (*AIResponse, error) {
|
||||
apiKey := os.Getenv("DEEPSEEK_API_KEY")
|
||||
baseURL := os.Getenv("DEEPSEEK_BASE_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.deepseek.com"
|
||||
}
|
||||
|
||||
model := os.Getenv("DEEPSEEK_MODEL")
|
||||
if model == "" {
|
||||
model = "deepseek-chat"
|
||||
}
|
||||
|
||||
if req.Model == "" {
|
||||
req.Model = model
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", baseURL+"/chat/completions", strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("DeepSeek API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var deepseekResp AIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&deepseekResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &deepseekResp, nil
|
||||
}
|
||||
|
||||
// callOllama calls Ollama API
|
||||
func (s *AIService) callOllama(req AIRequest) (*AIResponse, error) {
|
||||
baseURL := os.Getenv("OLLAMA_BASE_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:11434"
|
||||
}
|
||||
|
||||
model := os.Getenv("OLLAMA_MODEL")
|
||||
if model == "" {
|
||||
model = "llama3.1"
|
||||
}
|
||||
|
||||
if req.Model == "" {
|
||||
req.Model = model
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", baseURL+"/api/chat", strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second} // Ollama can be slower
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Ollama API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var ollamaResp AIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ollamaResp, nil
|
||||
}
|
||||
|
||||
// SetProvider changes the AI provider
|
||||
func (s *AIService) SetProvider(provider AIProvider) {
|
||||
s.provider = provider
|
||||
}
|
||||
|
||||
// GetProvider returns the current AI provider
|
||||
func (s *AIService) GetProvider() AIProvider {
|
||||
return s.provider
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ComputerVisionService provides computer vision capabilities
|
||||
type ComputerVisionService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewComputerVisionService creates a new computer vision service
|
||||
func NewComputerVisionService(db *gorm.DB) *ComputerVisionService {
|
||||
return &ComputerVisionService{db: db}
|
||||
}
|
||||
|
||||
// ImageAnalysisRequest represents a request for image analysis
|
||||
type ImageAnalysisRequest struct {
|
||||
ImageData string `json:"image_data" binding:"required"` // Base64 encoded image
|
||||
AnalysisType string `json:"analysis_type" binding:"required"` // ocr, objects, text, faces, all
|
||||
FileID *uint `json:"file_id,omitempty"`
|
||||
}
|
||||
|
||||
// ImageAnalysisResponse represents the result of image analysis
|
||||
type ImageAnalysisResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Analysis map[string]interface{} `json:"analysis"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Objects []ObjectDetection `json:"objects,omitempty"`
|
||||
Faces []FaceDetection `json:"faces,omitempty"`
|
||||
Metadata ImageMetadata `json:"metadata"`
|
||||
}
|
||||
|
||||
// ObjectDetection represents a detected object
|
||||
type ObjectDetection struct {
|
||||
Name string `json:"name"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
BoundingBox BoundingBox `json:"bounding_box"`
|
||||
}
|
||||
|
||||
// FaceDetection represents a detected face
|
||||
type FaceDetection struct {
|
||||
Confidence float64 `json:"confidence"`
|
||||
BoundingBox BoundingBox `json:"bounding_box"`
|
||||
Age *int `json:"age,omitempty"`
|
||||
Gender *string `json:"gender,omitempty"`
|
||||
Emotion *string `json:"emotion,omitempty"`
|
||||
}
|
||||
|
||||
// BoundingBox represents coordinates of a detected object
|
||||
type BoundingBox struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
// ImageMetadata represents metadata about the analyzed image
|
||||
type ImageMetadata struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Format string `json:"format"`
|
||||
SizeBytes int `json:"size_bytes"`
|
||||
ColorSpace string `json:"color_space"`
|
||||
DominantColors []string `json:"dominant_colors"`
|
||||
TextDensity float64 `json:"text_density"`
|
||||
}
|
||||
|
||||
// AnalyzeImage performs computer vision analysis on an image
|
||||
func (s *ComputerVisionService) AnalyzeImage(req ImageAnalysisRequest) (*ImageAnalysisResponse, error) {
|
||||
// Decode base64 image
|
||||
imageData, err := base64.StdEncoding.DecodeString(req.ImageData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid base64 image data: %v", err)
|
||||
}
|
||||
|
||||
// Parse image to get metadata
|
||||
img, format, err := image.Decode(bytes.NewReader(imageData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode image: %v", err)
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
response := &ImageAnalysisResponse{
|
||||
Success: true,
|
||||
Analysis: make(map[string]interface{}),
|
||||
Metadata: ImageMetadata{
|
||||
Width: bounds.Dx(),
|
||||
Height: bounds.Dy(),
|
||||
Format: format,
|
||||
SizeBytes: len(imageData),
|
||||
ColorSpace: "RGB", // Simplified
|
||||
},
|
||||
}
|
||||
|
||||
// Perform requested analysis types
|
||||
if req.AnalysisType == "ocr" || req.AnalysisType == "all" {
|
||||
text, err := s.extractText(imageData)
|
||||
if err == nil {
|
||||
response.Text = text
|
||||
response.Analysis["text"] = text
|
||||
response.Analysis["word_count"] = len(strings.Fields(text))
|
||||
response.Metadata.TextDensity = float64(len(text)) / float64(bounds.Dx()*bounds.Dy()) * 1000
|
||||
}
|
||||
}
|
||||
|
||||
if req.AnalysisType == "objects" || req.AnalysisType == "all" {
|
||||
objects := s.detectObjects(imageData)
|
||||
response.Objects = objects
|
||||
response.Analysis["objects"] = objects
|
||||
response.Analysis["object_count"] = len(objects)
|
||||
}
|
||||
|
||||
if req.AnalysisType == "faces" || req.AnalysisType == "all" {
|
||||
faces := s.detectFaces(imageData)
|
||||
response.Faces = faces
|
||||
response.Analysis["faces"] = faces
|
||||
response.Analysis["face_count"] = len(faces)
|
||||
}
|
||||
|
||||
if req.AnalysisType == "text" || req.AnalysisType == "all" {
|
||||
// Extract readable text from image
|
||||
text, err := s.extractText(imageData)
|
||||
if err == nil {
|
||||
response.Analysis["readable_text"] = text
|
||||
response.Analysis["has_text"] = len(strings.TrimSpace(text)) > 0
|
||||
}
|
||||
}
|
||||
|
||||
// Extract dominant colors
|
||||
colors := s.extractDominantColors(imageData)
|
||||
response.Metadata.DominantColors = colors
|
||||
|
||||
// Save analysis to database if file ID is provided
|
||||
if req.FileID != nil {
|
||||
s.saveImageAnalysis(*req.FileID, response)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// extractText performs OCR on the image (simplified implementation)
|
||||
func (s *ComputerVisionService) extractText(imageData []byte) (string, error) {
|
||||
// This is a simplified OCR implementation
|
||||
// In a real implementation, you would use:
|
||||
// - Tesseract OCR
|
||||
// - Google Cloud Vision API
|
||||
// - Azure Computer Vision
|
||||
// - AWS Textract
|
||||
|
||||
// For demo purposes, we'll extract text from common patterns
|
||||
// This is just a placeholder implementation
|
||||
|
||||
// Try to detect common text patterns in the image
|
||||
// In reality, this would require actual OCR processing
|
||||
|
||||
// Simulate OCR by returning sample text based on image analysis
|
||||
text := `
|
||||
This is sample OCR text extracted from the image.
|
||||
In a real implementation, this would contain the actual
|
||||
text content found in the image using OCR technology.
|
||||
|
||||
Common use cases:
|
||||
- Document scanning
|
||||
- Receipt processing
|
||||
- Business card reading
|
||||
- Screenshot text extraction
|
||||
`
|
||||
|
||||
return strings.TrimSpace(text), nil
|
||||
}
|
||||
|
||||
// detectObjects performs object detection on the image
|
||||
func (s *ComputerVisionService) detectObjects(imageData []byte) []ObjectDetection {
|
||||
// This is a simplified object detection implementation
|
||||
// In a real implementation, you would use:
|
||||
// - YOLO (You Only Look Once)
|
||||
// - TensorFlow Object Detection API
|
||||
// - OpenCV DNN
|
||||
// - Cloud vision services
|
||||
|
||||
// Simulate object detection with common objects
|
||||
objects := []ObjectDetection{
|
||||
{
|
||||
Name: "document",
|
||||
Confidence: 0.95,
|
||||
BoundingBox: BoundingBox{X: 10, Y: 10, Width: 300, Height: 400},
|
||||
},
|
||||
{
|
||||
Name: "text",
|
||||
Confidence: 0.88,
|
||||
BoundingBox: BoundingBox{X: 20, Y: 30, Width: 280, Height: 200},
|
||||
},
|
||||
{
|
||||
Name: "logo",
|
||||
Confidence: 0.72,
|
||||
BoundingBox: BoundingBox{X: 250, Y: 20, Width: 50, Height: 50},
|
||||
},
|
||||
}
|
||||
|
||||
return objects
|
||||
}
|
||||
|
||||
// detectFaces performs face detection on the image
|
||||
func (s *ComputerVisionService) detectFaces(imageData []byte) []FaceDetection {
|
||||
// This is a simplified face detection implementation
|
||||
// In a real implementation, you would use:
|
||||
// - OpenCV Face Detection
|
||||
// - Dlib
|
||||
// - FaceNet
|
||||
// - Cloud face detection services
|
||||
|
||||
// Simulate face detection
|
||||
faces := []FaceDetection{
|
||||
{
|
||||
Confidence: 0.92,
|
||||
BoundingBox: BoundingBox{X: 100, Y: 80, Width: 120, Height: 150},
|
||||
Age: func() *int { age := 28; return &age }(),
|
||||
Gender: func() *string { gender := "male"; return &gender }(),
|
||||
Emotion: func() *string { emotion := "happy"; return &emotion }(),
|
||||
},
|
||||
}
|
||||
|
||||
return faces
|
||||
}
|
||||
|
||||
// extractDominantColors extracts the dominant colors from the image
|
||||
func (s *ComputerVisionService) extractDominantColors(imageData []byte) []string {
|
||||
// This is a simplified color extraction
|
||||
// In a real implementation, you would use:
|
||||
// - K-means clustering
|
||||
// - Color histogram analysis
|
||||
// - Median cut algorithm
|
||||
|
||||
// Simulate dominant colors
|
||||
colors := []string{
|
||||
"#FFFFFF", // White
|
||||
"#333333", // Dark gray
|
||||
"#0066CC", // Blue
|
||||
"#FF6600", // Orange
|
||||
"#00CC66", // Green
|
||||
}
|
||||
|
||||
return colors
|
||||
}
|
||||
|
||||
// saveImageAnalysis saves the analysis results to the database
|
||||
func (s *ComputerVisionService) saveImageAnalysis(fileID uint, analysis *ImageAnalysisResponse) error {
|
||||
// Convert analysis to JSON for storage
|
||||
analysisJSON := fmt.Sprintf(`{
|
||||
"text": "%s",
|
||||
"object_count": %d,
|
||||
"face_count": %d,
|
||||
"metadata": %+v
|
||||
}`, analysis.Text, len(analysis.Objects), len(analysis.Faces), analysis.Metadata)
|
||||
|
||||
// Create or update file analysis record
|
||||
var fileAnalysis models.FileAnalysis
|
||||
err := s.db.Where("file_id = ?", fileID).First(&fileAnalysis).Error
|
||||
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Create new analysis record
|
||||
now := time.Now()
|
||||
fileAnalysis = models.FileAnalysis{
|
||||
FileID: fileID,
|
||||
AnalysisType: "computer_vision",
|
||||
Results: analysisJSON,
|
||||
Confidence: 0.85,
|
||||
ProcessedAt: &now,
|
||||
}
|
||||
return s.db.Create(&fileAnalysis).Error
|
||||
} else if err == nil {
|
||||
// Update existing record
|
||||
fileAnalysis.Results = analysisJSON
|
||||
now := time.Now()
|
||||
fileAnalysis.ProcessedAt = &now
|
||||
return s.db.Save(&fileAnalysis).Error
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ProcessDocumentImage processes a document image for text extraction and structure
|
||||
func (s *ComputerVisionService) ProcessDocumentImage(imageData []byte) (*DocumentAnalysis, error) {
|
||||
// Extract text using OCR
|
||||
text, err := s.extractText(imageData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Analyze document structure
|
||||
analysis := &DocumentAnalysis{
|
||||
Text: text,
|
||||
WordCount: len(strings.Fields(text)),
|
||||
LineCount: len(strings.Split(text, "\n")),
|
||||
Language: s.detectLanguage(text),
|
||||
DocumentType: s.detectDocumentType(text),
|
||||
Sections: s.extractSections(text),
|
||||
Tables: s.extractTables(text),
|
||||
Links: s.extractLinks(text),
|
||||
Emails: s.extractEmails(text),
|
||||
PhoneNumbers: s.extractPhoneNumbers(text),
|
||||
}
|
||||
|
||||
return analysis, nil
|
||||
}
|
||||
|
||||
// DocumentAnalysis represents the analysis of a document image
|
||||
type DocumentAnalysis struct {
|
||||
Text string `json:"text"`
|
||||
WordCount int `json:"word_count"`
|
||||
LineCount int `json:"line_count"`
|
||||
Language string `json:"language"`
|
||||
DocumentType string `json:"document_type"`
|
||||
Sections []DocumentSection `json:"sections"`
|
||||
Tables []DocumentTable `json:"tables"`
|
||||
Links []string `json:"links"`
|
||||
Emails []string `json:"emails"`
|
||||
PhoneNumbers []string `json:"phone_numbers"`
|
||||
}
|
||||
|
||||
// DocumentSection represents a section in a document
|
||||
type DocumentSection struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Level int `json:"level"`
|
||||
}
|
||||
|
||||
// DocumentTable represents a table in a document
|
||||
type DocumentTable struct {
|
||||
Headers []string `json:"headers"`
|
||||
Rows [][]string `json:"rows"`
|
||||
}
|
||||
|
||||
// detectLanguage detects the language of the text
|
||||
func (s *ComputerVisionService) detectLanguage(text string) string {
|
||||
// Simplified language detection
|
||||
// In a real implementation, you would use:
|
||||
// - Language detection libraries
|
||||
// - Machine learning models
|
||||
// - Cloud language detection services
|
||||
|
||||
if strings.Contains(strings.ToLower(text), "the") && strings.Contains(strings.ToLower(text), "and") {
|
||||
return "en"
|
||||
} else if strings.Contains(text, "est") && strings.Contains(text, "que") {
|
||||
return "es"
|
||||
} else if strings.Contains(text, "und") && strings.Contains(text, "der") {
|
||||
return "de"
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// detectDocumentType detects the type of document
|
||||
func (s *ComputerVisionService) detectDocumentType(text string) string {
|
||||
text = strings.ToLower(text)
|
||||
|
||||
if strings.Contains(text, "invoice") || strings.Contains(text, "bill") {
|
||||
return "invoice"
|
||||
} else if strings.Contains(text, "receipt") || strings.Contains(text, "purchase") {
|
||||
return "receipt"
|
||||
} else if strings.Contains(text, "resume") || strings.Contains(text, "curriculum") {
|
||||
return "resume"
|
||||
} else if strings.Contains(text, "contract") || strings.Contains(text, "agreement") {
|
||||
return "contract"
|
||||
} else if strings.Contains(text, "report") || strings.Contains(text, "analysis") {
|
||||
return "report"
|
||||
}
|
||||
|
||||
return "general"
|
||||
}
|
||||
|
||||
// extractSections extracts document sections
|
||||
func (s *ComputerVisionService) extractSections(text string) []DocumentSection {
|
||||
var sections []DocumentSection
|
||||
lines := strings.Split(text, "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Simple section detection (headers followed by content)
|
||||
if len(line) < 100 && (strings.HasSuffix(line, ":") || strings.ToUpper(line) == line) {
|
||||
sections = append(sections, DocumentSection{
|
||||
Title: line,
|
||||
Content: "",
|
||||
Level: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
// extractTables extracts tables from the text
|
||||
func (s *ComputerVisionService) extractTables(text string) []DocumentTable {
|
||||
// Simplified table extraction
|
||||
// In a real implementation, this would be much more sophisticated
|
||||
var tables []DocumentTable
|
||||
|
||||
// Look for tabular data patterns
|
||||
lines := strings.Split(text, "\n")
|
||||
for i, line := range lines {
|
||||
if strings.Contains(line, "\t") || strings.Contains(line, " ") {
|
||||
// Potential table row
|
||||
if i > 0 && strings.Contains(lines[i-1], "\t") {
|
||||
// Multiple consecutive rows with tabs - likely a table
|
||||
table := DocumentTable{
|
||||
Headers: strings.Split(lines[i-1], "\t"),
|
||||
Rows: [][]string{strings.Split(line, "\t")},
|
||||
}
|
||||
tables = append(tables, table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tables
|
||||
}
|
||||
|
||||
// extractLinks extracts URLs from the text
|
||||
func (s *ComputerVisionService) extractLinks(text string) []string {
|
||||
urlRegex := regexp.MustCompile(`https?://[^\s]+`)
|
||||
return urlRegex.FindAllString(text, -1)
|
||||
}
|
||||
|
||||
// extractEmails extracts email addresses from the text
|
||||
func (s *ComputerVisionService) extractEmails(text string) []string {
|
||||
emailRegex := regexp.MustCompile(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`)
|
||||
return emailRegex.FindAllString(text, -1)
|
||||
}
|
||||
|
||||
// extractPhoneNumbers extracts phone numbers from the text
|
||||
func (s *ComputerVisionService) extractPhoneNumbers(text string) []string {
|
||||
phoneRegex := regexp.MustCompile(`\b\d{3}[-.]?\d{3}[-.]?\d{4}\b`)
|
||||
return phoneRegex.FindAllString(text, -1)
|
||||
}
|
||||
|
||||
// CreateFileAnalysis creates a file analysis record
|
||||
func (s *ComputerVisionService) CreateFileAnalysis(fileID uint, analysisType, results string, confidence float64) error {
|
||||
now := time.Now()
|
||||
fileAnalysis := models.FileAnalysis{
|
||||
FileID: fileID,
|
||||
AnalysisType: analysisType,
|
||||
Results: results,
|
||||
Confidence: confidence,
|
||||
ProcessedAt: &now,
|
||||
}
|
||||
|
||||
return s.db.Create(&fileAnalysis).Error
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WebsiteMetadata represents extracted website information
|
||||
type WebsiteMetadata struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Favicon string `json:"favicon"`
|
||||
SiteName string `json:"site_name"`
|
||||
Image string `json:"image"`
|
||||
Author string `json:"author"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
}
|
||||
|
||||
// FetchWebsiteMetadata extracts metadata from a URL
|
||||
func FetchWebsiteMetadata(targetURL string) (*WebsiteMetadata, error) {
|
||||
// Parse URL to ensure it's valid
|
||||
parsedURL, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
// Create HTTP client with timeout
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// Make request
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set user agent to avoid being blocked
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
content := string(body)
|
||||
metadata := &WebsiteMetadata{}
|
||||
|
||||
// Extract Open Graph and Twitter Card metadata
|
||||
metadata = extractOpenGraphMetadata(content, metadata)
|
||||
metadata = extractTwitterMetadata(content, metadata)
|
||||
metadata = extractBasicHTMLMetadata(content, metadata)
|
||||
|
||||
// Extract favicon
|
||||
if metadata.Favicon == "" {
|
||||
metadata.Favicon = extractFavicon(content, parsedURL)
|
||||
}
|
||||
|
||||
// If still no favicon, try default locations
|
||||
if metadata.Favicon == "" {
|
||||
metadata.Favicon = getDefaultFavicon(parsedURL)
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// extractOpenGraphMetadata extracts Open Graph meta tags
|
||||
func extractOpenGraphMetadata(content string, metadata *WebsiteMetadata) *WebsiteMetadata {
|
||||
// This is a simple implementation - in production, you might want to use a proper HTML parser
|
||||
ogPatterns := map[string]string{
|
||||
`<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']`: "Title",
|
||||
`<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']`: "Description",
|
||||
`<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']`: "Image",
|
||||
`<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']`: "SiteName",
|
||||
`<meta[^>]+property=["']article:author["'][^>]+content=["']([^"']+)["']`: "Author",
|
||||
`<meta[^>]+property=["']article:published_time["'][^>]+content=["']([^"']+)["']`: "PublishedAt",
|
||||
}
|
||||
|
||||
for pattern, field := range ogPatterns {
|
||||
if re := regexp.MustCompile(pattern); re != nil {
|
||||
if matches := re.FindStringSubmatch(content); len(matches) > 1 {
|
||||
switch field {
|
||||
case "Title":
|
||||
metadata.Title = matches[1]
|
||||
case "Description":
|
||||
metadata.Description = matches[1]
|
||||
case "Image":
|
||||
metadata.Image = matches[1]
|
||||
case "SiteName":
|
||||
metadata.SiteName = matches[1]
|
||||
case "Author":
|
||||
metadata.Author = matches[1]
|
||||
case "PublishedAt":
|
||||
metadata.PublishedAt = matches[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
// extractTwitterMetadata extracts Twitter Card meta tags
|
||||
func extractTwitterMetadata(content string, metadata *WebsiteMetadata) *WebsiteMetadata {
|
||||
twitterPatterns := map[string]string{
|
||||
`<meta[^>]+name=["']twitter:title["'][^>]+content=["']([^"']+)["']`: "Title",
|
||||
`<meta[^>]+name=["']twitter:description["'][^>]+content=["']([^"']+)["']`: "Description",
|
||||
`<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']`: "Image",
|
||||
`<meta[^>]+name=["']twitter:site["'][^>]+content=["']([^"']+)["']`: "SiteName",
|
||||
`<meta[^>]+name=["']twitter:creator["'][^>]+content=["']([^"']+)["']`: "Author",
|
||||
}
|
||||
|
||||
for pattern, field := range twitterPatterns {
|
||||
if re := regexp.MustCompile(pattern); re != nil {
|
||||
if matches := re.FindStringSubmatch(content); len(matches) > 1 {
|
||||
// Only set if not already set by Open Graph
|
||||
switch field {
|
||||
case "Title":
|
||||
if metadata.Title == "" {
|
||||
metadata.Title = matches[1]
|
||||
}
|
||||
case "Description":
|
||||
if metadata.Description == "" {
|
||||
metadata.Description = matches[1]
|
||||
}
|
||||
case "Image":
|
||||
if metadata.Image == "" {
|
||||
metadata.Image = matches[1]
|
||||
}
|
||||
case "SiteName":
|
||||
if metadata.SiteName == "" {
|
||||
metadata.SiteName = matches[1]
|
||||
}
|
||||
case "Author":
|
||||
if metadata.Author == "" {
|
||||
metadata.Author = matches[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
// extractBasicHTMLMetadata extracts basic HTML title and description
|
||||
func extractBasicHTMLMetadata(content string, metadata *WebsiteMetadata) *WebsiteMetadata {
|
||||
// Extract title
|
||||
if metadata.Title == "" {
|
||||
if re := regexp.MustCompile(`<title[^>]*>([^<]+)</title>`); re != nil {
|
||||
if matches := re.FindStringSubmatch(content); len(matches) > 1 {
|
||||
metadata.Title = strings.TrimSpace(matches[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract description meta tag
|
||||
if metadata.Description == "" {
|
||||
if re := regexp.MustCompile(`<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']`); re != nil {
|
||||
if matches := re.FindStringSubmatch(content); len(matches) > 1 {
|
||||
metadata.Description = matches[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
// extractFavicon extracts favicon from HTML with enhanced detection
|
||||
func extractFavicon(content string, baseURL *url.URL) string {
|
||||
// Enhanced patterns for favicon detection
|
||||
patterns := []string{
|
||||
// Standard favicon link tags
|
||||
`<link[^>]+rel=["'](?:icon|shortcut icon)["'][^>]+href=["']([^"']+)["']`,
|
||||
`<link[^>]+href=["']([^"']+)["'][^>]+rel=["'](?:icon|shortcut icon)["']`,
|
||||
|
||||
// Apple touch icons
|
||||
`<link[^>]+rel=["']apple-touch-icon["'][^>]+href=["']([^"']+)["']`,
|
||||
`<link[^>]+href=["']([^"']+)["'][^>]+rel=["']apple-touch-icon["']`,
|
||||
|
||||
// Apple touch icon precomposed
|
||||
`<link[^>]+rel=["']apple-touch-icon-precomposed["'][^>]+href=["']([^"']+)["']`,
|
||||
`<link[^>]+href=["']([^"']+)["'][^>]+rel=["']apple-touch-icon-precomposed["']`,
|
||||
|
||||
// Android icons
|
||||
`<link[^>]+rel=["']android-chrome-[\w\-\d]+["'][^>]+href=["']([^"']+)["']`,
|
||||
`<link[^>]+href=["']([^"']+)["'][^>]+rel=["']android-chrome-[\w\-\d]+["']`,
|
||||
|
||||
// Microsoft tiles
|
||||
`<meta[^>]+name=["']msapplication-TileImage["'][^>]+content=["']([^"']+)["']`,
|
||||
|
||||
// Open Graph image (can be used as logo)
|
||||
`<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']`,
|
||||
|
||||
// Twitter image
|
||||
`<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']`,
|
||||
|
||||
// Logo patterns
|
||||
`<link[^>]+rel=["']logo["'][^>]+href=["']([^"']+)["']`,
|
||||
`<link[^>]+href=["']([^"']+)["'][^>]+rel=["']logo["']`,
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
if re := regexp.MustCompile(pattern); re != nil {
|
||||
if matches := re.FindStringSubmatch(content); len(matches) > 1 {
|
||||
href := matches[1]
|
||||
// Convert relative URL to absolute
|
||||
if strings.HasPrefix(href, "/") {
|
||||
return baseURL.Scheme + "://" + baseURL.Host + href
|
||||
} else if !strings.HasPrefix(href, "http") {
|
||||
return baseURL.Scheme + "://" + baseURL.Host + "/" + href
|
||||
}
|
||||
return href
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// getDefaultFavicon tries common favicon locations with enhanced detection
|
||||
func getDefaultFavicon(baseURL *url.URL) string {
|
||||
commonPaths := []string{
|
||||
"/favicon.ico",
|
||||
"/favicon.png",
|
||||
"/favicon.svg",
|
||||
"/apple-touch-icon.png",
|
||||
"/apple-touch-icon-precomposed.png",
|
||||
"/android-chrome-192x192.png",
|
||||
"/icon-192x192.png",
|
||||
"/touch-icon-192x192.png",
|
||||
"/logo.png",
|
||||
"/logo.svg",
|
||||
"/assets/favicon.ico",
|
||||
"/assets/favicon.png",
|
||||
"/static/favicon.ico",
|
||||
"/static/favicon.png",
|
||||
"/images/favicon.ico",
|
||||
"/images/favicon.png",
|
||||
}
|
||||
|
||||
for _, path := range commonPaths {
|
||||
faviconURL := baseURL.Scheme + "://" + baseURL.Host + path
|
||||
|
||||
// Check if favicon exists with a quick HEAD request
|
||||
if resp, err := http.Head(faviconURL); err == nil && resp.StatusCode == http.StatusOK {
|
||||
// Check content type to ensure it's an image
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(contentType, "image/") {
|
||||
return faviconURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find high-resolution favicons from common CDNs
|
||||
host := baseURL.Host
|
||||
if !strings.Contains(host, "www.") {
|
||||
host = "www." + host
|
||||
}
|
||||
|
||||
// Try Google's favicon service with higher resolution
|
||||
return fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", baseURL.Host)
|
||||
}
|
||||
|
||||
// CacheService handles caching of metadata
|
||||
type CacheService struct {
|
||||
cache map[string]*WebsiteMetadata
|
||||
}
|
||||
|
||||
func NewCacheService() *CacheService {
|
||||
return &CacheService{
|
||||
cache: make(map[string]*WebsiteMetadata),
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *CacheService) Get(key string) (*WebsiteMetadata, bool) {
|
||||
if metadata, exists := cs.cache[key]; exists {
|
||||
return metadata, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (cs *CacheService) Set(key string, metadata *WebsiteMetadata) {
|
||||
cs.cache[key] = metadata
|
||||
}
|
||||
|
||||
// Global cache instance
|
||||
var metadataCache = NewCacheService()
|
||||
|
||||
// GetCachedMetadata fetches metadata with caching
|
||||
func GetCachedMetadata(url string) (*WebsiteMetadata, error) {
|
||||
// Create cache key
|
||||
cacheKey := fmt.Sprintf("%x", md5.Sum([]byte(url)))
|
||||
|
||||
// Try to get from cache
|
||||
if metadata, exists := metadataCache.Get(cacheKey); exists {
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// Fetch fresh metadata
|
||||
metadata, err := FetchWebsiteMetadata(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
metadataCache.Set(cacheKey, metadata)
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PerformanceService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPerformanceService(db *gorm.DB) *PerformanceService {
|
||||
return &PerformanceService{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// OptimizeDatabase performs database optimizations
|
||||
func (s *PerformanceService) OptimizeDatabase() error {
|
||||
// Create indexes for frequently queried fields
|
||||
indexes := []string{
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email ON users(email)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_username ON users(username)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_bookmarks_user_id ON bookmarks(user_id)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_bookmarks_created_at ON bookmarks(created_at)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_user_id ON tasks(user_id)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_status ON tasks(status)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_created_at ON tasks(created_at)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_notes_user_id ON notes(user_id)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_notes_is_public ON notes(is_public)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_files_user_id ON files(user_id)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_files_created_at ON files(created_at)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_time_entries_user_id ON time_entries(user_id)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_time_entries_created_at ON time_entries(created_at)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_audit_logs_action ON audit_logs(action)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_audit_logs_resource ON audit_logs(resource)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_marketplace_items_status ON marketplace_items(status)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_marketplace_items_category ON marketplace_items(category)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_challenges_status ON challenges(status)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_challenges_start_date ON challenges(start_date)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_challenge_participants_user_id ON challenge_participants(user_id)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_challenge_participants_challenge_id ON challenge_participants(challenge_id)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_mentorships_status ON mentorships(status)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_mentorships_mentor_id ON mentorships(mentor_id)",
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_mentorships_mentee_id ON mentorships(mentee_id)",
|
||||
}
|
||||
|
||||
for _, indexSQL := range indexes {
|
||||
if err := s.db.Exec(indexSQL).Error; err != nil {
|
||||
// Log error but continue with other indexes
|
||||
fmt.Printf("Failed to create index: %s, error: %v\n", indexSQL, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze tables to update statistics
|
||||
tables := []string{
|
||||
"users", "bookmarks", "tasks", "notes", "files",
|
||||
"time_entries", "audit_logs", "marketplace_items",
|
||||
"challenges", "challenge_participants", "mentorships",
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
if err := s.db.Exec(fmt.Sprintf("ANALYZE %s", table)).Error; err != nil {
|
||||
fmt.Printf("Failed to analyze table %s: %v\n", table, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupOldAuditLogs removes old audit logs to maintain performance
|
||||
func (s *PerformanceService) CleanupOldAuditLogs(retentionDays int) error {
|
||||
cutoffDate := time.Now().AddDate(0, 0, -retentionDays)
|
||||
|
||||
result := s.db.Where("created_at < ?", cutoffDate).Delete(&struct{}{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
fmt.Printf("Cleaned up %d old audit log entries\n", result.RowsAffected)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDatabaseStats returns database performance statistics
|
||||
func (s *PerformanceService) GetDatabaseStats() (map[string]interface{}, error) {
|
||||
stats := make(map[string]interface{})
|
||||
|
||||
// Get table sizes
|
||||
var tableStats []struct {
|
||||
TableName string `json:"table_name"`
|
||||
RowCount int64 `json:"row_count"`
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
table_name as table_name,
|
||||
n_tup_ins - n_tup_del as row_count
|
||||
FROM pg_stat_user_tables
|
||||
ORDER BY n_tup_ins - n_tup_del DESC
|
||||
LIMIT 10
|
||||
`
|
||||
|
||||
if err := s.db.Raw(query).Scan(&tableStats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats["table_sizes"] = tableStats
|
||||
|
||||
// Get slow queries (if pg_stat_statements is available)
|
||||
var slowQueries []struct {
|
||||
Query string `json:"query"`
|
||||
Calls int64 `json:"calls"`
|
||||
TotalTime float64 `json:"total_time"`
|
||||
MeanTime float64 `json:"mean_time"`
|
||||
}
|
||||
|
||||
slowQuerySQL := `
|
||||
SELECT
|
||||
query,
|
||||
calls,
|
||||
total_time,
|
||||
mean_time
|
||||
FROM pg_stat_statements
|
||||
ORDER BY mean_time DESC
|
||||
LIMIT 10
|
||||
`
|
||||
|
||||
if err := s.db.Raw(slowQuerySQL).Scan(&slowQueries).Error; err == nil {
|
||||
stats["slow_queries"] = slowQueries
|
||||
}
|
||||
|
||||
// Get cache hit ratio
|
||||
var cacheStats struct {
|
||||
HitRatio float64 `json:"hit_ratio"`
|
||||
}
|
||||
|
||||
cacheSQL := `
|
||||
SELECT
|
||||
ROUND(sum(heap_blks_hit)::numeric /
|
||||
(sum(heap_blks_hit) + sum(heap_blks_read)), 4) as hit_ratio
|
||||
FROM pg_statio_user_tables
|
||||
`
|
||||
|
||||
if err := s.db.Raw(cacheSQL).Scan(&cacheStats).Error; err == nil {
|
||||
stats["cache_hit_ratio"] = cacheStats.HitRatio
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// OptimizeQueries optimizes common query patterns
|
||||
func (s *PerformanceService) OptimizeQueries() error {
|
||||
// Enable query plan caching
|
||||
if err := s.db.Exec("SET plan_cache_mode = force_generic_plan").Error; err != nil {
|
||||
fmt.Printf("Failed to set plan cache mode: %v\n", err)
|
||||
}
|
||||
|
||||
// Set appropriate work_mem for sorting operations
|
||||
if err := s.db.Exec("SET work_mem = '16MB'").Error; err != nil {
|
||||
fmt.Printf("Failed to set work_mem: %v\n", err)
|
||||
}
|
||||
|
||||
// Set maintenance_work_mem for index creation
|
||||
if err := s.db.Exec("SET maintenance_work_mem = '64MB'").Error; err != nil {
|
||||
fmt.Printf("Failed to set maintenance_work_mem: %v\n", err)
|
||||
}
|
||||
|
||||
// Enable parallel query processing
|
||||
if err := s.db.Exec("SET max_parallel_workers_per_gather = 2").Error; err != nil {
|
||||
fmt.Printf("Failed to set max_parallel_workers_per_gather: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MonitorPerformance monitors system performance
|
||||
func (s *PerformanceService) MonitorPerformance() (map[string]interface{}, error) {
|
||||
stats := make(map[string]interface{})
|
||||
|
||||
// Database connections
|
||||
var dbStats struct {
|
||||
ActiveConnections int `json:"active_connections"`
|
||||
MaxConnections int `json:"max_connections"`
|
||||
}
|
||||
|
||||
if err := s.db.Raw("SELECT count(*) as active_connections FROM pg_stat_activity WHERE state = 'active'").Scan(&dbStats.ActiveConnections).Error; err == nil {
|
||||
s.db.Raw("SHOW max_connections").Scan(&dbStats.MaxConnections)
|
||||
stats["database_connections"] = dbStats
|
||||
}
|
||||
|
||||
// Redis stats (if available) - currently not implemented
|
||||
stats["redis_info"] = "Redis not configured"
|
||||
|
||||
// Memory usage
|
||||
var memoryStats struct {
|
||||
TotalMemory int64 `json:"total_memory"`
|
||||
UsedMemory int64 `json:"used_memory"`
|
||||
}
|
||||
|
||||
if err := s.db.Raw("SELECT setting::int * 1024 * 1024 as total_memory FROM pg_settings WHERE name = 'shared_buffers'").Scan(&memoryStats.TotalMemory).Error; err == nil {
|
||||
stats["memory_usage"] = memoryStats
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// WarmupCache preloads frequently accessed data into cache
|
||||
// Currently not implemented - requires Redis or other caching solution
|
||||
func (s *PerformanceService) WarmupCache() error {
|
||||
// TODO: Implement caching when Redis is added
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearCache clears all cache entries
|
||||
// Currently not implemented - requires Redis or other caching solution
|
||||
func (s *PerformanceService) ClearCache() error {
|
||||
// TODO: Implement cache clearing when Redis is added
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCacheStats returns cache performance statistics
|
||||
// Currently not implemented - requires Redis or other caching solution
|
||||
func (s *PerformanceService) GetCacheStats() (map[string]interface{}, error) {
|
||||
return map[string]interface{}{"status": "cache_not_implemented"}, nil
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// VideoBookmarkService handles video bookmark operations
|
||||
type VideoBookmarkService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewVideoBookmarkService creates a new video bookmark service
|
||||
func NewVideoBookmarkService(db *gorm.DB) *VideoBookmarkService {
|
||||
return &VideoBookmarkService{db: db}
|
||||
}
|
||||
|
||||
// VideoInfo represents video information from scraper
|
||||
type VideoInfo struct {
|
||||
VideoID string `json:"video_id"`
|
||||
Title string `json:"title"`
|
||||
Channel string `json:"channel"`
|
||||
Thumbnail string `json:"thumbnail_url"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// SaveVideoRequest represents the request to save a video
|
||||
type SaveVideoRequest struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Tags string `json:"tags"`
|
||||
IsFavorite bool `json:"is_favorite"`
|
||||
}
|
||||
|
||||
// SaveVideoBookmark saves a video bookmark
|
||||
func (vbs *VideoBookmarkService) SaveVideoBookmark(userID uint, req SaveVideoRequest) (*models.VideoBookmark, error) {
|
||||
// Extract video info using scraper
|
||||
videoInfo, err := vbs.extractVideoInfo(req.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract video info: %w", err)
|
||||
}
|
||||
|
||||
if !videoInfo.Success {
|
||||
return nil, fmt.Errorf("scraper error: %s", videoInfo.Error)
|
||||
}
|
||||
|
||||
// Check if video already bookmarked by this user
|
||||
var existingBookmark models.VideoBookmark
|
||||
if err := vbs.db.Where("user_id = ? AND video_id = ?", userID, videoInfo.VideoID).First(&existingBookmark).Error; err == nil {
|
||||
return nil, fmt.Errorf("video already bookmarked")
|
||||
}
|
||||
|
||||
// Create bookmark
|
||||
bookmark := models.VideoBookmark{
|
||||
VideoID: videoInfo.VideoID,
|
||||
Title: videoInfo.Title,
|
||||
Channel: videoInfo.Channel,
|
||||
Thumbnail: videoInfo.Thumbnail,
|
||||
URL: req.URL,
|
||||
UserID: userID,
|
||||
Description: req.Description,
|
||||
Tags: req.Tags,
|
||||
IsFavorite: req.IsFavorite,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := vbs.db.Create(&bookmark).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to save bookmark: %w", err)
|
||||
}
|
||||
|
||||
return &bookmark, nil
|
||||
}
|
||||
|
||||
// GetUserBookmarks gets all bookmarks for a user
|
||||
func (vbs *VideoBookmarkService) GetUserBookmarks(userID uint, limit int, offset int) ([]models.VideoBookmark, error) {
|
||||
var bookmarks []models.VideoBookmark
|
||||
|
||||
query := vbs.db.Where("user_id = ?", userID).Order("created_at DESC")
|
||||
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
|
||||
if offset > 0 {
|
||||
query = query.Offset(offset)
|
||||
}
|
||||
|
||||
if err := query.Find(&bookmarks).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to get bookmarks: %w", err)
|
||||
}
|
||||
|
||||
return bookmarks, nil
|
||||
}
|
||||
|
||||
// GetBookmarkByID gets a bookmark by ID
|
||||
func (vbs *VideoBookmarkService) GetBookmarkByID(userID uint, bookmarkID uint) (*models.VideoBookmark, error) {
|
||||
var bookmark models.VideoBookmark
|
||||
if err := vbs.db.Where("id = ? AND user_id = ?", bookmarkID, userID).First(&bookmark).Error; err != nil {
|
||||
return nil, fmt.Errorf("bookmark not found: %w", err)
|
||||
}
|
||||
return &bookmark, nil
|
||||
}
|
||||
|
||||
// UpdateBookmark updates a bookmark
|
||||
func (vbs *VideoBookmarkService) UpdateBookmark(userID uint, bookmarkID uint, req SaveVideoRequest) (*models.VideoBookmark, error) {
|
||||
bookmark, err := vbs.GetBookmarkByID(userID, bookmarkID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update fields
|
||||
bookmark.Description = req.Description
|
||||
bookmark.Tags = req.Tags
|
||||
bookmark.IsFavorite = req.IsFavorite
|
||||
bookmark.UpdatedAt = time.Now()
|
||||
|
||||
if err := vbs.db.Save(bookmark).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to update bookmark: %w", err)
|
||||
}
|
||||
|
||||
return bookmark, nil
|
||||
}
|
||||
|
||||
// DeleteBookmark deletes a bookmark
|
||||
func (vbs *VideoBookmarkService) DeleteBookmark(userID uint, bookmarkID uint) error {
|
||||
result := vbs.db.Where("id = ? AND user_id = ?", bookmarkID, userID).Delete(&models.VideoBookmark{})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("failed to delete bookmark: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("bookmark not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToggleWatched toggles the watched status of a bookmark
|
||||
func (vbs *VideoBookmarkService) ToggleWatched(userID uint, bookmarkID uint) (*models.VideoBookmark, error) {
|
||||
bookmark, err := vbs.GetBookmarkByID(userID, bookmarkID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bookmark.IsWatched = !bookmark.IsWatched
|
||||
bookmark.UpdatedAt = time.Now()
|
||||
|
||||
if err := vbs.db.Save(bookmark).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to update bookmark: %w", err)
|
||||
}
|
||||
|
||||
return bookmark, nil
|
||||
}
|
||||
|
||||
// ToggleFavorite toggles the favorite status of a bookmark
|
||||
func (vbs *VideoBookmarkService) ToggleFavorite(userID uint, bookmarkID uint) (*models.VideoBookmark, error) {
|
||||
bookmark, err := vbs.GetBookmarkByID(userID, bookmarkID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bookmark.IsFavorite = !bookmark.IsFavorite
|
||||
bookmark.UpdatedAt = time.Now()
|
||||
|
||||
if err := vbs.db.Save(bookmark).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to update bookmark: %w", err)
|
||||
}
|
||||
|
||||
return bookmark, nil
|
||||
}
|
||||
|
||||
// SearchBookmarks searches bookmarks by title, channel, or tags
|
||||
func (vbs *VideoBookmarkService) SearchBookmarks(userID uint, query string, limit int, offset int) ([]models.VideoBookmark, error) {
|
||||
var bookmarks []models.VideoBookmark
|
||||
|
||||
searchQuery := "%" + query + "%"
|
||||
dbQuery := vbs.db.Where("user_id = ? AND (title LIKE ? OR channel LIKE ? OR tags LIKE ?)",
|
||||
userID, searchQuery, searchQuery, searchQuery).Order("created_at DESC")
|
||||
|
||||
if limit > 0 {
|
||||
dbQuery = dbQuery.Limit(limit)
|
||||
}
|
||||
|
||||
if offset > 0 {
|
||||
dbQuery = dbQuery.Offset(offset)
|
||||
}
|
||||
|
||||
if err := dbQuery.Find(&bookmarks).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to search bookmarks: %w", err)
|
||||
}
|
||||
|
||||
return bookmarks, nil
|
||||
}
|
||||
|
||||
// GetBookmarkStats gets statistics about user's bookmarks
|
||||
func (vbs *VideoBookmarkService) GetBookmarkStats(userID uint) (map[string]interface{}, error) {
|
||||
stats := make(map[string]interface{})
|
||||
|
||||
// Total bookmarks
|
||||
var total int64
|
||||
if err := vbs.db.Model(&models.VideoBookmark{}).Where("user_id = ?", userID).Count(&total).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to get total count: %w", err)
|
||||
}
|
||||
stats["total"] = total
|
||||
|
||||
// Watched bookmarks
|
||||
var watched int64
|
||||
if err := vbs.db.Model(&models.VideoBookmark{}).Where("user_id = ? AND is_watched = ?", userID, true).Count(&watched).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to get watched count: %w", err)
|
||||
}
|
||||
stats["watched"] = watched
|
||||
|
||||
// Favorite bookmarks
|
||||
var favorites int64
|
||||
if err := vbs.db.Model(&models.VideoBookmark{}).Where("user_id = ? AND is_favorite = ?", userID, true).Count(&favorites).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to get favorites count: %w", err)
|
||||
}
|
||||
stats["favorites"] = favorites
|
||||
|
||||
// Unwatched bookmarks
|
||||
stats["unwatched"] = total - watched
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// extractVideoInfo extracts video information using the scraper service
|
||||
func (vbs *VideoBookmarkService) extractVideoInfo(url string) (*VideoInfo, error) {
|
||||
// In demo mode, create mock data
|
||||
if os.Getenv("VITE_DEMO_MODE") == "true" {
|
||||
return &VideoInfo{
|
||||
VideoID: "demo123",
|
||||
Title: "Demo Video Title",
|
||||
Channel: "Demo Channel",
|
||||
Thumbnail: "https://i.ytimg.com/vi/demo123/hqdefault.jpg",
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Call the scraper service
|
||||
scraperURL := fmt.Sprintf("http://youtube-video-scraper:7858/video")
|
||||
|
||||
req, err := http.NewRequest("POST", scraperURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Create request body
|
||||
reqBody := fmt.Sprintf(`{"url": "%s"}`, url)
|
||||
req.Body = nil // Will be set below
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Post(scraperURL, "application/json", strings.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to call scraper service: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("scraper service returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var videoInfo VideoInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&videoInfo); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &videoInfo, nil
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// YouTubeVideo represents a YouTube video
|
||||
type YouTubeVideo struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Duration string `json:"duration"`
|
||||
ViewCount int64 `json:"view_count"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
ChannelTitle string `json:"channel_title"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
}
|
||||
|
||||
// VideoItem represents a video item from the youtube scraping service
|
||||
type VideoItem struct {
|
||||
VideoID string `json:"video_id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Length string `json:"length,omitempty"`
|
||||
ThumbnailURL string `json:"thumbnail_url,omitempty"`
|
||||
ViewsText string `json:"views_text,omitempty"`
|
||||
Views int64 `json:"views"`
|
||||
PublishedText string `json:"published_text,omitempty"`
|
||||
PublishedDate string `json:"published_date,omitempty"`
|
||||
ChannelName string `json:"channel_name,omitempty"`
|
||||
}
|
||||
|
||||
// YouTubeSearchResponse represents the response from YouTube search API
|
||||
type YouTubeSearchResponse struct {
|
||||
Videos []YouTubeVideo `json:"videos"`
|
||||
NextPageToken string `json:"next_page_token,omitempty"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
// YouTubeService handles YouTube API interactions
|
||||
type YouTubeService struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewYouTubeService creates a new YouTube service instance
|
||||
func NewYouTubeService() *YouTubeService {
|
||||
return &YouTubeService{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SearchVideos searches for YouTube videos using direct scraping
|
||||
func (ys *YouTubeService) SearchVideos(query string, maxResults int, pageToken string) (*YouTubeSearchResponse, error) {
|
||||
// For new implementation, we always return 1 result as requested
|
||||
videoID, channelName, err := ys.fetchYouTubeVideoIDAndChannel(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search YouTube: %w", err)
|
||||
}
|
||||
|
||||
// Create response with single video
|
||||
video := YouTubeVideo{
|
||||
ID: videoID,
|
||||
Title: fmt.Sprintf("Video: %s", query),
|
||||
ChannelTitle: channelName,
|
||||
Thumbnail: fmt.Sprintf("https://img.youtube.com/vi/%s/maxresdefault.jpg", videoID),
|
||||
}
|
||||
|
||||
return &YouTubeSearchResponse{
|
||||
Videos: []YouTubeVideo{video},
|
||||
TotalResults: 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchYouTubeVideoIDAndChannel scrapes YouTube to get video ID and channel name
|
||||
func (ys *YouTubeService) fetchYouTubeVideoIDAndChannel(query string) (string, string, error) {
|
||||
youtubeSearchURL := fmt.Sprintf("https://www.youtube.com/results?search_query=%s", strings.ReplaceAll(query, " ", "+"))
|
||||
|
||||
resp, err := ys.httpClient.Get(youtubeSearchURL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("error fetching YouTube search results: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("error reading response body: %w", err)
|
||||
}
|
||||
|
||||
// Extract video ID using regex
|
||||
videoRe := regexp.MustCompile(`"videoRenderer":{"videoId":"([^"]{11})"`)
|
||||
videoMatches := videoRe.FindStringSubmatch(string(body))
|
||||
if len(videoMatches) < 2 {
|
||||
return "", "", fmt.Errorf("no video found for query: %s", query)
|
||||
}
|
||||
videoID := videoMatches[1]
|
||||
|
||||
// Extract channel name using regex
|
||||
channelRe := regexp.MustCompile(`"longBylineText":{"runs":\[{"text":"([^"]+)"`)
|
||||
channelMatches := channelRe.FindStringSubmatch(string(body))
|
||||
channelName := ""
|
||||
if len(channelMatches) >= 2 {
|
||||
channelName = channelMatches[1]
|
||||
}
|
||||
|
||||
return videoID, channelName, nil
|
||||
}
|
||||
|
||||
// GetChannelVideosFromURL extracts videos from a YouTube channel URL
|
||||
func (ys *YouTubeService) GetChannelVideosFromURL(channelURL string, maxResults int) (*YouTubeSearchResponse, error) {
|
||||
// Extract channel handle from URL
|
||||
channelHandle, err := ys.extractChannelHandle(channelURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid channel URL: %w", err)
|
||||
}
|
||||
|
||||
// Fetch channel videos
|
||||
videos, err := ys.fetchChannelVideos(channelHandle, maxResults)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch channel videos: %w", err)
|
||||
}
|
||||
|
||||
return &YouTubeSearchResponse{
|
||||
Videos: videos,
|
||||
TotalResults: len(videos),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractChannelHandle extracts channel handle from YouTube URL
|
||||
func (ys *YouTubeService) extractChannelHandle(channelURL string) (string, error) {
|
||||
// Handle different URL formats
|
||||
if strings.Contains(channelURL, "/@") {
|
||||
// Extract handle from @username format
|
||||
re := regexp.MustCompile(`/@([^/?]+)`)
|
||||
matches := re.FindStringSubmatch(channelURL)
|
||||
if len(matches) >= 2 {
|
||||
return "@" + matches[1], nil
|
||||
}
|
||||
} else if strings.Contains(channelURL, "/channel/") {
|
||||
// Extract channel ID from /channel/ID format
|
||||
re := regexp.MustCompile(`/channel/([^/?]+)`)
|
||||
matches := re.FindStringSubmatch(channelURL)
|
||||
if len(matches) >= 2 {
|
||||
return matches[1], nil
|
||||
}
|
||||
} else if strings.Contains(channelURL, "/c/") {
|
||||
// Extract custom handle from /c/handle format
|
||||
re := regexp.MustCompile(`/c/([^/?]+)`)
|
||||
matches := re.FindStringSubmatch(channelURL)
|
||||
if len(matches) >= 2 {
|
||||
return matches[1], nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("unable to extract channel handle from URL: %s", channelURL)
|
||||
}
|
||||
|
||||
// fetchChannelVideos calls the YouTube scraper service for channel videos
|
||||
func (ys *YouTubeService) fetchChannelVideos(channelHandle string, maxResults int) ([]YouTubeVideo, error) {
|
||||
// Call the YouTube scraper service
|
||||
resp, err := http.Get(fmt.Sprintf("http://youtube-scraper:7857/channel_videos?channel=%s", channelHandle))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error calling scraper service: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading response body: %w", err)
|
||||
}
|
||||
|
||||
// Parse the scraper service response
|
||||
var scraperResponse struct {
|
||||
Channel string `json:"channel"`
|
||||
ChannelURL string `json:"channel_url"`
|
||||
Videos []struct {
|
||||
VideoID string `json:"video_id"`
|
||||
Title string `json:"title"`
|
||||
ThumbnailURL string `json:"thumbnail_url"`
|
||||
Views int `json:"views"`
|
||||
ViewsText string `json:"views_text"`
|
||||
PublishedText string `json:"published_text"`
|
||||
PublishedDate string `json:"published_date"`
|
||||
} `json:"videos"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &scraperResponse); err != nil {
|
||||
return nil, fmt.Errorf("error parsing scraper response: %w", err)
|
||||
}
|
||||
|
||||
// Convert to YouTubeVideo format
|
||||
var videos []YouTubeVideo
|
||||
for i, video := range scraperResponse.Videos {
|
||||
if i >= maxResults {
|
||||
break
|
||||
}
|
||||
|
||||
ytVideo := YouTubeVideo{
|
||||
ID: video.VideoID,
|
||||
Title: video.Title,
|
||||
Thumbnail: video.ThumbnailURL,
|
||||
ViewCount: int64(video.Views),
|
||||
PublishedAt: video.PublishedDate,
|
||||
ChannelTitle: scraperResponse.Channel,
|
||||
}
|
||||
videos = append(videos, ytVideo)
|
||||
}
|
||||
|
||||
return videos, nil
|
||||
}
|
||||
|
||||
// GetVideoDetails retrieves basic information about a specific video
|
||||
func (ys *YouTubeService) GetVideoDetails(videoID string) (*YouTubeVideo, error) {
|
||||
// For simplicity, return basic video info
|
||||
video := YouTubeVideo{
|
||||
ID: videoID,
|
||||
Title: fmt.Sprintf("Video %s", videoID),
|
||||
Thumbnail: fmt.Sprintf("https://img.youtube.com/vi/%s/maxresdefault.jpg", videoID),
|
||||
Description: "Video details not available in this implementation",
|
||||
}
|
||||
|
||||
return &video, nil
|
||||
}
|
||||
|
||||
// GetChannelVideos retrieves videos from a specific channel (legacy method)
|
||||
func (ys *YouTubeService) GetChannelVideos(channelID string, maxResults int, pageToken string) (*YouTubeSearchResponse, error) {
|
||||
// Always use integrated YouTube channel service - no more external service calls
|
||||
return GetYouTubeChannelVideosIntegrated(channelID, maxResults)
|
||||
}
|
||||
|
||||
// Global YouTube service instance
|
||||
var youtubeService = NewYouTubeService()
|
||||
|
||||
// SearchYouTubeVideos is a convenience function for searching videos
|
||||
func SearchYouTubeVideos(query string, maxResults int, pageToken string) (*YouTubeSearchResponse, error) {
|
||||
// Always use integrated YouTube search - no more mock data
|
||||
return SearchYouTubeVideosIntegrated(query, maxResults)
|
||||
}
|
||||
|
||||
// YouTubeSearchVideo represents a YouTube video from search
|
||||
type YouTubeSearchVideo struct {
|
||||
VideoID string `json:"video_id"`
|
||||
Title string `json:"title"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
Description string `json:"description"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
}
|
||||
|
||||
// fetchYouTubeVideosReal calls the working search service on port 7857
|
||||
func fetchYouTubeVideosReal(query string, limit int) ([]YouTubeSearchVideo, error) {
|
||||
// URL encode the query to handle spaces properly
|
||||
encodedQuery := url.QueryEscape(query)
|
||||
// Use localhost for development or Docker service name for container-to-container communication
|
||||
youtubeServiceURL := os.Getenv("YOUTUBE_SERVICE_URL")
|
||||
if youtubeServiceURL == "" {
|
||||
youtubeServiceURL = "http://localhost:7857"
|
||||
}
|
||||
url := fmt.Sprintf("%s/youtube?q=%s", youtubeServiceURL, encodedQuery)
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check for rate limiting
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("YouTube is rate limiting us. Please try again later.")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("YouTube search service returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the JSON response from the search service (it returns an array)
|
||||
var videos []YouTubeSearchVideo
|
||||
if err := json.Unmarshal(body, &videos); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse search service response: %v", err)
|
||||
}
|
||||
|
||||
// Limit results if needed
|
||||
if len(videos) > limit {
|
||||
videos = videos[:limit]
|
||||
}
|
||||
|
||||
return videos, nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// htmlUnescape fixes escaped sequences in HTML strings
|
||||
func htmlUnescape(s string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
" ", " ",
|
||||
"&", "&",
|
||||
""", `"`,
|
||||
"'", "'",
|
||||
)
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
|
||||
// searchYouTubeVideosReal calls the real YouTube search scraper
|
||||
func searchYouTubeVideosReal(query string, maxResults int) (*YouTubeSearchResponse, error) {
|
||||
// Perform real YouTube search scraping
|
||||
videos, err := fetchYouTubeVideosReal(query, maxResults)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert search results to YouTubeVideo format
|
||||
var ytVideos []YouTubeVideo
|
||||
for _, video := range videos {
|
||||
ytVideo := YouTubeVideo{
|
||||
ID: video.VideoID,
|
||||
Title: video.Title,
|
||||
Thumbnail: video.Thumbnail,
|
||||
ViewCount: 0, // Not available from search
|
||||
PublishedAt: "", // Not available from search
|
||||
ChannelTitle: video.ChannelName,
|
||||
}
|
||||
ytVideos = append(ytVideos, ytVideo)
|
||||
}
|
||||
|
||||
return &YouTubeSearchResponse{
|
||||
Videos: ytVideos,
|
||||
TotalResults: len(ytVideos),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetYouTubeVideoDetails is a convenience function for getting video details
|
||||
func GetYouTubeVideoDetails(videoID string) (*YouTubeVideo, error) {
|
||||
return youtubeService.GetVideoDetails(videoID)
|
||||
}
|
||||
|
||||
// GetYouTubeChannelVideos is a convenience function for getting channel videos
|
||||
func GetYouTubeChannelVideos(channelID string, maxResults int, pageToken string) (*YouTubeSearchResponse, error) {
|
||||
// Always use integrated YouTube channel service - no more mock data
|
||||
return GetYouTubeChannelVideosIntegrated(channelID, maxResults)
|
||||
}
|
||||
|
||||
// getYouTubeChannelVideosReal calls the YouTube scraper service
|
||||
func getYouTubeChannelVideosReal(channelID string, maxResults int) (*YouTubeSearchResponse, error) {
|
||||
// Call the YouTube scraper service using Docker service name
|
||||
resp, err := http.Get(fmt.Sprintf("http://youtube-scraper:7857/channel_videos?channel=%s", channelID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to call YouTube scraper service: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("YouTube scraper service returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// Parse the response from the scraper service
|
||||
var scraperResponse struct {
|
||||
Channel string `json:"channel"`
|
||||
Videos []struct {
|
||||
VideoID string `json:"video_id"`
|
||||
Title string `json:"title"`
|
||||
Length string `json:"length"`
|
||||
ThumbnailURL string `json:"thumbnail_url"`
|
||||
Views int64 `json:"views"`
|
||||
PublishedText string `json:"published_text"`
|
||||
PublishedDate string `json:"published_date"`
|
||||
} `json:"videos"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &scraperResponse); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse scraper response: %w", err)
|
||||
}
|
||||
|
||||
// Convert to our YouTubeVideo format
|
||||
var videos []YouTubeVideo
|
||||
for _, video := range scraperResponse.Videos {
|
||||
ytVideo := YouTubeVideo{
|
||||
ID: video.VideoID,
|
||||
Title: video.Title,
|
||||
Thumbnail: video.ThumbnailURL,
|
||||
Duration: video.Length,
|
||||
ViewCount: video.Views,
|
||||
PublishedAt: video.PublishedDate,
|
||||
ChannelTitle: scraperResponse.Channel,
|
||||
}
|
||||
videos = append(videos, ytVideo)
|
||||
}
|
||||
|
||||
// Limit results if needed
|
||||
if len(videos) > maxResults {
|
||||
videos = videos[:maxResults]
|
||||
}
|
||||
|
||||
return &YouTubeSearchResponse{
|
||||
Videos: videos,
|
||||
TotalResults: len(videos),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PredefinedChannel represents a predefined YouTube channel
|
||||
type PredefinedChannel struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Handle string `json:"handle"`
|
||||
}
|
||||
|
||||
// GetPredefinedChannelVideos gets the latest videos from predefined channels
|
||||
func GetPredefinedChannelVideos(maxResults int) (*YouTubeSearchResponse, error) {
|
||||
// Always use real YouTube channel service - no more demo mode mock data
|
||||
// Use the predefined channels from youtube_channels.go
|
||||
channels := []PredefinedChannel{
|
||||
{ID: "UC9x0YY7RmP2x0v_yEUE0rLA", Name: "NetworkChuck", Handle: "@NetworkChuck"},
|
||||
{ID: "UCsBjURrPoezykLs9EqH2YWw", Name: "Fireship", Handle: "@Fireship"},
|
||||
{ID: "UCaBHI8xMtM5I4p3tAH_eW5Q", Name: "Beyond Fireship", Handle: "@beyondfireship"},
|
||||
{ID: "UC_x5XG1OV2P6uZZ5FSM9Ttw", Name: "Traversy Media", Handle: "@traversy_media"},
|
||||
{ID: "UC8butISFwT-Wl7EV0hUK0BQ", Name: "Tyler McGinnis", Handle: "@tylermcginnis"},
|
||||
}
|
||||
var allVideos []YouTubeVideo
|
||||
|
||||
// Get videos from each channel
|
||||
for _, channel := range channels {
|
||||
response, err := GetYouTubeChannelVideos(channel.Handle, maxResults, "")
|
||||
if err != nil {
|
||||
// Continue with other channels if one fails
|
||||
continue
|
||||
}
|
||||
allVideos = append(allVideos, response.Videos...)
|
||||
}
|
||||
|
||||
// Return combined response
|
||||
return &YouTubeSearchResponse{
|
||||
Videos: allVideos,
|
||||
TotalResults: len(allVideos),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getMockVideoDetails returns mock video data for demo mode
|
||||
func (ys *YouTubeService) getMockVideoDetails(videoID string) *YouTubeVideo {
|
||||
// Generate some mock data based on video ID
|
||||
mockTitles := []string{
|
||||
"Amazing Tech Tutorial",
|
||||
"Web Development Tips",
|
||||
"Programming Best Practices",
|
||||
"JavaScript Framework Comparison",
|
||||
"Building Modern Web Apps",
|
||||
}
|
||||
|
||||
mockChannels := []string{
|
||||
"Fireship",
|
||||
"NetworkChuck",
|
||||
"Beyond Fireship",
|
||||
"Tech With Tim",
|
||||
"Programming with Mosh",
|
||||
}
|
||||
|
||||
// Use video ID to deterministically select mock data
|
||||
titleIndex := len(videoID) % len(mockTitles)
|
||||
channelIndex := (len(videoID) + 1) % len(mockChannels)
|
||||
|
||||
return &YouTubeVideo{
|
||||
ID: videoID,
|
||||
Title: mockTitles[titleIndex],
|
||||
Description: "This is a mock video description for demo mode. The original video details could not be fetched, but this demonstrates the functionality.",
|
||||
Thumbnail: fmt.Sprintf("https://img.youtube.com/vi/%s/maxresdefault.jpg", videoID),
|
||||
Duration: "10:24",
|
||||
ViewCount: int64(1000 + (len(videoID) * 100)),
|
||||
PublishedAt: "2024-01-15",
|
||||
ChannelTitle: mockChannels[channelIndex],
|
||||
ChannelID: "mock_channel_id",
|
||||
}
|
||||
}
|
||||
|
||||
// getMockYouTubeVideos returns mock YouTube videos for demo mode
|
||||
func getMockYouTubeVideos(query string, maxResults int) (*YouTubeSearchResponse, error) {
|
||||
// Mock video data
|
||||
mockVideos := []YouTubeVideo{
|
||||
{
|
||||
ID: "MOCK-VIDEO-1",
|
||||
Title: "MOCK: Never Gonna Give You Up - Rick Astley",
|
||||
Description: "The official video for 'Never Gonna Give You Up' by Rick Astley",
|
||||
Thumbnail: "https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg",
|
||||
Duration: "3:33",
|
||||
ViewCount: 1500000000,
|
||||
PublishedAt: "2009-10-25",
|
||||
ChannelTitle: "Rick Astley",
|
||||
ChannelID: "UCuAXFkgsw1L7xaCfnd5CJOA",
|
||||
},
|
||||
{
|
||||
ID: "MOCK-VIDEO-2",
|
||||
Title: "MOCK: Me at the zoo - The first YouTube video",
|
||||
Description: "The first video on YouTube, uploaded by Jawed Karim",
|
||||
Thumbnail: "https://img.youtube.com/vi/jNQXAC9IVRw/maxresdefault.jpg",
|
||||
Duration: "0:19",
|
||||
ViewCount: 300000000,
|
||||
PublishedAt: "2005-04-23",
|
||||
ChannelTitle: "Jawed Karim",
|
||||
ChannelID: "UC4QobL6k2pFkE-vtCS5wZTA",
|
||||
},
|
||||
{
|
||||
ID: "MOCK-VIDEO-3",
|
||||
Title: "MOCK: PSY - GANGNAM STYLE (강남스타일) M/V",
|
||||
Description: "Psy's official music video for 'Gangnam Style'",
|
||||
Thumbnail: "https://img.youtube.com/vi/9bZkp7q19f0/maxresdefault.jpg",
|
||||
Duration: "4:13",
|
||||
ViewCount: 5000000000,
|
||||
PublishedAt: "2012-07-15",
|
||||
ChannelTitle: "officialpsy",
|
||||
ChannelID: "UCrEw2n_aDR1I7k2kI2L2tJA",
|
||||
},
|
||||
{
|
||||
ID: "MOCK-VIDEO-4",
|
||||
Title: "MOCK: Luis Fonsi - Despacito ft. Daddy Yankee",
|
||||
Description: "Official music video for 'Despacito' by Luis Fonsi",
|
||||
Thumbnail: "https://img.youtube.com/vi/kJQP7kiw5Fk/maxresdefault.jpg",
|
||||
Duration: "4:41",
|
||||
ViewCount: 8000000000,
|
||||
PublishedAt: "2017-01-12",
|
||||
ChannelTitle: "Luis Fonsi",
|
||||
ChannelID: "UCrgInDaT3M4n1qZ6-xJbR9A",
|
||||
},
|
||||
{
|
||||
ID: "MOCK-VIDEO-5",
|
||||
Title: "MOCK: Introduction to React Programming",
|
||||
Description: "Learn the basics of React programming in this comprehensive tutorial",
|
||||
Thumbnail: "https://img.youtube.com/vi/hTWKbfoikeg/maxresdefault.jpg",
|
||||
Duration: "15:30",
|
||||
ViewCount: 250000,
|
||||
PublishedAt: "2024-01-15",
|
||||
ChannelTitle: "Programming Tutorials",
|
||||
ChannelID: "UC1234567890",
|
||||
},
|
||||
{
|
||||
ID: "MOCK-VIDEO-6",
|
||||
Title: "MOCK: Docker Containerization Explained",
|
||||
Description: "Complete guide to Docker containers and orchestration",
|
||||
Thumbnail: "https://img.youtube.com/vi/abc123def456/maxresdefault.jpg",
|
||||
Duration: "22:15",
|
||||
ViewCount: 180000,
|
||||
PublishedAt: "2024-01-10",
|
||||
ChannelTitle: "DevOps Simplified",
|
||||
ChannelID: "UC0987654321",
|
||||
},
|
||||
{
|
||||
ID: "MOCK-VIDEO-7",
|
||||
Title: "MOCK: Machine Learning Fundamentals",
|
||||
Description: "Introduction to machine learning algorithms and concepts",
|
||||
Thumbnail: "https://img.youtube.com/vi/xyz789uvw012/maxresdefault.jpg",
|
||||
Duration: "18:45",
|
||||
ViewCount: 320000,
|
||||
PublishedAt: "2024-01-08",
|
||||
ChannelTitle: "AI Education",
|
||||
ChannelID: "UC1122334455",
|
||||
},
|
||||
{
|
||||
ID: "MOCK-VIDEO-8",
|
||||
Title: "MOCK: Web Development Best Practices 2024",
|
||||
Description: "Modern web development techniques and best practices",
|
||||
Thumbnail: "https://img.youtube.com/vi/def456ghi789/maxresdefault.jpg",
|
||||
Duration: "25:10",
|
||||
ViewCount: 145000,
|
||||
PublishedAt: "2024-01-12",
|
||||
ChannelTitle: "Web Dev Weekly",
|
||||
ChannelID: "UC5566778899",
|
||||
},
|
||||
{
|
||||
ID: "MOCK-VIDEO-9",
|
||||
Title: "MOCK: JavaScript Advanced Concepts",
|
||||
Description: "Deep dive into JavaScript advanced features and patterns",
|
||||
Thumbnail: "https://img.youtube.com/vi/ghi789jkl012/maxresdefault.jpg",
|
||||
Duration: "32:20",
|
||||
ViewCount: 425000,
|
||||
PublishedAt: "2024-01-05",
|
||||
ChannelTitle: "JS Masters",
|
||||
ChannelID: "UC9988776655",
|
||||
},
|
||||
}
|
||||
|
||||
// For demo mode, return all videos (up to maxResults) regardless of query
|
||||
var filteredVideos []YouTubeVideo
|
||||
for i, video := range mockVideos {
|
||||
if i >= maxResults {
|
||||
break
|
||||
}
|
||||
filteredVideos = append(filteredVideos, video)
|
||||
}
|
||||
|
||||
return &YouTubeSearchResponse{
|
||||
Videos: filteredVideos,
|
||||
TotalResults: len(filteredVideos),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// YouTubeCacheService handles caching YouTube channel data
|
||||
type YouTubeCacheService struct {
|
||||
db *gorm.DB
|
||||
cache map[string]*CacheEntry
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// CacheEntry represents an in-memory cache entry
|
||||
type CacheEntry struct {
|
||||
Videos string `json:"videos"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
// NewYouTubeCacheService creates a new YouTube cache service
|
||||
func NewYouTubeCacheService(db *gorm.DB) *YouTubeCacheService {
|
||||
return &YouTubeCacheService{
|
||||
db: db,
|
||||
cache: make(map[string]*CacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// GetCachedChannelVideos retrieves cached channel videos or fetches fresh data
|
||||
func (y *YouTubeCacheService) GetCachedChannelVideos(channelID string, maxResults int) (*YouTubeSearchResponse, error) {
|
||||
// Always use real YouTube data - no more demo mode
|
||||
|
||||
// Try to get from database cache first
|
||||
var cache models.YouTubeChannelCache
|
||||
if err := y.db.Where("channel_id = ?", channelID).First(&cache); err == nil {
|
||||
// Check if cache is still valid
|
||||
if !cache.IsExpired() {
|
||||
// Return cached data
|
||||
var videos []YouTubeVideo
|
||||
if err := json.Unmarshal([]byte(cache.Videos), &videos); err == nil {
|
||||
// Limit results if needed
|
||||
if len(videos) > maxResults {
|
||||
videos = videos[:maxResults]
|
||||
}
|
||||
return &YouTubeSearchResponse{
|
||||
Videos: videos,
|
||||
TotalResults: len(videos),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache is expired or doesn't exist, fetch fresh data
|
||||
return y.fetchAndCacheVideos(channelID, maxResults)
|
||||
}
|
||||
|
||||
// getInMemoryCachedVideos retrieves cached videos from memory (for demo mode)
|
||||
func (y *YouTubeCacheService) getInMemoryCachedVideos(channelID string, maxResults int) (*YouTubeSearchResponse, error) {
|
||||
y.mutex.RLock()
|
||||
defer y.mutex.RUnlock()
|
||||
|
||||
if entry, exists := y.cache[channelID]; exists {
|
||||
// Check if cache is still valid (2 hours)
|
||||
if time.Since(entry.LastUpdated) < 2*time.Hour {
|
||||
// Return cached data
|
||||
var videos []YouTubeVideo
|
||||
if err := json.Unmarshal([]byte(entry.Videos), &videos); err == nil {
|
||||
// Limit results if needed
|
||||
if len(videos) > maxResults {
|
||||
videos = videos[:maxResults]
|
||||
}
|
||||
return &YouTubeSearchResponse{
|
||||
Videos: videos,
|
||||
TotalResults: len(videos),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache is expired or doesn't exist, fetch fresh data
|
||||
return y.fetchAndCacheVideos(channelID, maxResults)
|
||||
}
|
||||
|
||||
// fetchAndCacheVideos fetches fresh data and caches it
|
||||
func (y *YouTubeCacheService) fetchAndCacheVideos(channelID string, maxResults int) (*YouTubeSearchResponse, error) {
|
||||
// Fetch from YouTube scraper service
|
||||
resp, err := http.Get(fmt.Sprintf("http://youtube-scraper:7857/channel_videos?channel=%s", channelID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch channel videos: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check for rate limiting
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("YouTube is rate limiting us. Please try again later.")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("YouTube scraper service returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading response body: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG: fetchAndCacheVideos response for %s: %s\n", channelID, string(body[:min(500, len(body))]))
|
||||
|
||||
// Parse the scraper service response
|
||||
var scraperResponse struct {
|
||||
Channel string `json:"channel"`
|
||||
ChannelURL string `json:"channel_url"`
|
||||
Videos []struct {
|
||||
VideoID string `json:"video_id"`
|
||||
Title string `json:"title"`
|
||||
ThumbnailURL string `json:"thumbnail_url"`
|
||||
Views int `json:"views"`
|
||||
ViewsText string `json:"views_text"`
|
||||
PublishedText string `json:"published_text"`
|
||||
PublishedDate string `json:"published_date"`
|
||||
} `json:"videos"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &scraperResponse); err != nil {
|
||||
return nil, fmt.Errorf("error parsing scraper response: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG: Parsed %d videos for channel %s\n", len(scraperResponse.Videos), channelID)
|
||||
|
||||
// Convert to YouTubeVideo format
|
||||
var videos []YouTubeVideo
|
||||
for i, video := range scraperResponse.Videos {
|
||||
if i >= maxResults {
|
||||
break
|
||||
}
|
||||
|
||||
ytVideo := YouTubeVideo{
|
||||
ID: video.VideoID,
|
||||
Title: video.Title,
|
||||
Thumbnail: video.ThumbnailURL,
|
||||
ViewCount: int64(video.Views),
|
||||
PublishedAt: video.PublishedDate,
|
||||
ChannelTitle: scraperResponse.Channel,
|
||||
}
|
||||
videos = append(videos, ytVideo)
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG: Converted %d videos for channel %s\n", len(videos), channelID)
|
||||
|
||||
// Cache the results
|
||||
videosJSON, err := json.Marshal(videos)
|
||||
if err != nil {
|
||||
log.Printf("Error marshaling videos for cache: %v", err)
|
||||
} else {
|
||||
// Save to database cache
|
||||
cache := models.YouTubeChannelCache{
|
||||
ChannelID: channelID,
|
||||
ChannelName: scraperResponse.Channel,
|
||||
ChannelURL: scraperResponse.ChannelURL,
|
||||
Videos: string(videosJSON),
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
|
||||
// Use upsert to handle both create and update
|
||||
y.db.Where("channel_id = ?", channelID).Assign(&cache).FirstOrCreate(&cache)
|
||||
fmt.Printf("DEBUG: Cached %d videos in database for channel %s\n", len(videos), channelID)
|
||||
}
|
||||
|
||||
return &YouTubeSearchResponse{
|
||||
Videos: videos,
|
||||
TotalResults: len(videos),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ClearExpiredCache removes expired cache entries
|
||||
func (y *YouTubeCacheService) ClearExpiredCache() error {
|
||||
// Always use database cache - no more demo mode
|
||||
|
||||
// Clear database cache
|
||||
expiredTime := time.Now().Add(-2 * time.Hour)
|
||||
return y.db.Where("last_updated < ?", expiredTime).Delete(&models.YouTubeChannelCache{}).Error
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// YouTubeChannelService handles specific channel integrations
|
||||
type YouTubeChannelService struct {
|
||||
YouTubeService *YouTubeService
|
||||
CacheService *YouTubeCacheService
|
||||
}
|
||||
|
||||
// NewYouTubeChannelService creates a new instance of YouTubeChannelService
|
||||
func NewYouTubeChannelService(youtubeService *YouTubeService, cacheService *YouTubeCacheService) *YouTubeChannelService {
|
||||
return &YouTubeChannelService{
|
||||
YouTubeService: youtubeService,
|
||||
CacheService: cacheService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetPredefinedChannels returns the list of predefined channels
|
||||
func GetPredefinedChannels() []Channel {
|
||||
return []Channel{
|
||||
{
|
||||
ID: "fireship",
|
||||
Name: "Fireship",
|
||||
Description: "Rapid web development tutorials and courses",
|
||||
Thumbnail: "https://img.youtube.com/vi/UCsBjURrPoezykLs9EqgAJVQ/mqdefault.jpg",
|
||||
},
|
||||
{
|
||||
ID: "networkchuck",
|
||||
Name: "NetworkChuck",
|
||||
Description: "Cybersecurity and networking tutorials",
|
||||
Thumbnail: "https://img.youtube.com/vi/UCNlz1cb4DvEx7rTnT2s7B3A/mqdefault.jpg",
|
||||
},
|
||||
{
|
||||
ID: "programmingwithmosh",
|
||||
Name: "Programming with Mosh",
|
||||
Description: "Comprehensive programming tutorials",
|
||||
Thumbnail: "https://img.youtube.com/vi/UC8butUNob-8kuy47X7vH6ws/mqdefault.jpg",
|
||||
},
|
||||
{
|
||||
ID: "traversymedia",
|
||||
Name: "Traversy Media",
|
||||
Description: "Web development and design tutorials",
|
||||
Thumbnail: "https://img.youtube.com/vi/UC29J8QxEQ7QmM0TJ_8Jt_gQ/mqdefault.jpg",
|
||||
},
|
||||
{
|
||||
ID: "thenewboston",
|
||||
Name: "The New Boston",
|
||||
Description: "Computer science and programming courses",
|
||||
Thumbnail: "https://img.youtube.com/vi/UCrwkHaJ-9Sd74Kx1n-9Qjg/mqdefault.jpg",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Channel represents a YouTube channel
|
||||
type Channel struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
}
|
||||
|
||||
// GetFireshipVideos fetches latest videos from Fireship channel
|
||||
func (ycs *YouTubeChannelService) GetFireshipVideos(limit int) ([]YouTubeVideo, error) {
|
||||
// Use cached data to avoid rate limiting
|
||||
response, err := ycs.CacheService.GetCachedChannelVideos("fireship", limit)
|
||||
if err != nil {
|
||||
if err.Error() == "YouTube is rate limiting us. Please try again later." {
|
||||
// Return rate limiting error
|
||||
return nil, fmt.Errorf("YouTube is rate limiting us. Please try again later.")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to fetch Fireship videos: %w", err)
|
||||
}
|
||||
|
||||
// Return all videos without filtering
|
||||
return response.Videos, nil
|
||||
}
|
||||
|
||||
// GetNetworkChuckVideos fetches latest videos from Network Chuck channel
|
||||
func (ycs *YouTubeChannelService) GetNetworkChuckVideos(limit int) ([]YouTubeVideo, error) {
|
||||
// Use cached data to avoid rate limiting
|
||||
response, err := ycs.CacheService.GetCachedChannelVideos("networkchuck", limit)
|
||||
if err != nil {
|
||||
if err.Error() == "YouTube is rate limiting us. Please try again later." {
|
||||
// Return rate limiting error
|
||||
return nil, fmt.Errorf("YouTube is rate limiting us. Please try again later.")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to fetch Network Chuck videos: %w", err)
|
||||
}
|
||||
|
||||
// Return all videos without filtering
|
||||
return response.Videos, nil
|
||||
}
|
||||
|
||||
// GetChannelInfo fetches basic information about a channel
|
||||
func (ycs *YouTubeChannelService) GetChannelInfo(channelID string) (*ChannelInfo, error) {
|
||||
// For now, return basic info from predefined channels
|
||||
channels := GetPredefinedChannels()
|
||||
for _, channel := range channels {
|
||||
if channel.ID == channelID {
|
||||
return &ChannelInfo{
|
||||
ID: channel.ID,
|
||||
Title: channel.Name,
|
||||
Description: channel.Description,
|
||||
Thumbnail: channel.Thumbnail,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("channel not found")
|
||||
}
|
||||
|
||||
// ChannelInfo represents basic channel information
|
||||
type ChannelInfo struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// parseYouTubeDuration converts ISO 8601 duration string to seconds
|
||||
func parseYouTubeDuration(duration string) int {
|
||||
// YouTube duration format: PT4M13S (4 minutes 13 seconds)
|
||||
// Simple parser for common formats
|
||||
seconds := 0
|
||||
current := 0
|
||||
|
||||
for _, char := range duration {
|
||||
switch char {
|
||||
case 'H', 'h':
|
||||
seconds += current * 3600
|
||||
current = 0
|
||||
case 'M', 'm':
|
||||
seconds += current * 60
|
||||
current = 0
|
||||
case 'S', 's':
|
||||
seconds += current
|
||||
current = 0
|
||||
case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
|
||||
current = current*10 + int(char-'0')
|
||||
}
|
||||
}
|
||||
|
||||
return seconds
|
||||
}
|
||||
|
||||
func containsIgnoreCase(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr ||
|
||||
(len(s) > len(substr) &&
|
||||
(s[:len(substr)] == substr ||
|
||||
s[len(s)-len(substr):] == substr ||
|
||||
containsSubstringIgnoreCase(s, substr))))
|
||||
}
|
||||
|
||||
func containsSubstringIgnoreCase(s, substr string) bool {
|
||||
s = toLower(s)
|
||||
substr = toLower(substr)
|
||||
return contains(s, substr)
|
||||
}
|
||||
|
||||
func toLower(s string) string {
|
||||
result := make([]rune, len([]rune(s)))
|
||||
for i, r := range []rune(s) {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
result[i] = r + ('a' - 'A')
|
||||
} else {
|
||||
result[i] = r
|
||||
}
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isRecentVideo(publishedAt string, months int) bool {
|
||||
// Parse the published date (ISO 8601 format)
|
||||
layout := "2006-01-02T15:04:05Z"
|
||||
publishedTime, err := time.Parse(layout, publishedAt)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the video is within the specified months
|
||||
cutoffTime := time.Now().AddDate(0, -months, 0)
|
||||
return publishedTime.After(cutoffTime)
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
// YouTubeIntegratedService provides all YouTube functionality in one service
|
||||
type YouTubeIntegratedService struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewYouTubeIntegratedService creates a new integrated YouTube service
|
||||
func NewYouTubeIntegratedService() *YouTubeIntegratedService {
|
||||
return &YouTubeIntegratedService{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SearchVideosIntegrated performs YouTube video search
|
||||
func (y *YouTubeIntegratedService) SearchVideosIntegrated(query string, limit int) ([]YouTubeSearchVideo, error) {
|
||||
url := fmt.Sprintf(
|
||||
"https://www.youtube.com/results?search_query=%s",
|
||||
url.QueryEscape(query),
|
||||
)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
|
||||
|
||||
resp, err := y.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
html := string(body)
|
||||
videoRe := regexp.MustCompile(`"videoRenderer":{"videoId":"([^"]{11})"`)
|
||||
|
||||
results := []YouTubeSearchVideo{}
|
||||
seen := map[string]bool{}
|
||||
|
||||
videoMatches := videoRe.FindAllStringSubmatchIndex(html, -1)
|
||||
|
||||
for _, match := range videoMatches {
|
||||
if len(results) >= limit {
|
||||
break
|
||||
}
|
||||
|
||||
if len(match) < 4 {
|
||||
continue
|
||||
}
|
||||
|
||||
videoID := html[match[2]:match[3]]
|
||||
if _, ok := seen[videoID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[videoID] = true
|
||||
|
||||
// Extract title and channel from surrounding context
|
||||
start := match[0]
|
||||
if start-2000 > 0 {
|
||||
start = start - 2000
|
||||
}
|
||||
end := match[1] + 2000
|
||||
if end > len(html) {
|
||||
end = len(html)
|
||||
}
|
||||
snippet := html[start:end]
|
||||
|
||||
title := ""
|
||||
channel := ""
|
||||
|
||||
if m := regexp.MustCompile(`"title":\{"runs":\[\{"text":"([^"]+)"`).FindStringSubmatch(snippet); len(m) >= 2 {
|
||||
title = unescapeYT(m[1])
|
||||
} else if m := regexp.MustCompile(`"title":\{"simpleText":"([^"]+)"`).FindStringSubmatch(snippet); len(m) >= 2 {
|
||||
title = unescapeYT(m[1])
|
||||
}
|
||||
|
||||
if m := regexp.MustCompile(`"longBylineText":\{"runs":\[\{"text":"([^"]+)"`).FindStringSubmatch(snippet); len(m) >= 2 {
|
||||
channel = unescapeYT(m[1])
|
||||
}
|
||||
|
||||
if title == "" {
|
||||
title = "Video " + videoID
|
||||
}
|
||||
|
||||
results = append(results, YouTubeSearchVideo{
|
||||
VideoID: videoID,
|
||||
Title: title,
|
||||
ChannelName: channel,
|
||||
Thumbnail: fmt.Sprintf("https://img.youtube.com/vi/%s/maxresdefault.jpg", videoID),
|
||||
})
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ChannelVideosResponse represents the response for channel videos scraping
|
||||
type ChannelVideosResponse struct {
|
||||
Channel string `json:"channel"`
|
||||
ChannelURL string `json:"channel_url"`
|
||||
SubscribersText string `json:"subscribers_text"`
|
||||
Subscribers int64 `json:"subscribers"`
|
||||
Videos []VideoItem `json:"videos"`
|
||||
}
|
||||
|
||||
// GetChannelVideosIntegrated fetches channel videos directly
|
||||
func (y *YouTubeIntegratedService) GetChannelVideosIntegrated(channelInput string) (ChannelVideosResponse, error) {
|
||||
handle, channelURL := normalizeChannelInput(channelInput)
|
||||
|
||||
req, err := http.NewRequest("GET", channelURL, nil)
|
||||
if err != nil {
|
||||
return ChannelVideosResponse{}, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
|
||||
resp, err := y.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return ChannelVideosResponse{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return ChannelVideosResponse{}, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return ChannelVideosResponse{}, err
|
||||
}
|
||||
html := string(body)
|
||||
|
||||
// Extract video IDs and metadata
|
||||
vidRe := regexp.MustCompile(`"videoRenderer":\{[^}]*?"videoId":"([a-zA-Z0-9_-]{11})"`)
|
||||
matches := vidRe.FindAllStringSubmatchIndex(html, -1)
|
||||
seen := make(map[string]struct{})
|
||||
var videos []VideoItem
|
||||
|
||||
for _, idx := range matches {
|
||||
if len(idx) < 4 {
|
||||
continue
|
||||
}
|
||||
|
||||
videoID := html[idx[2]:idx[3]]
|
||||
if _, ok := seen[videoID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[videoID] = struct{}{}
|
||||
|
||||
start := idx[0]
|
||||
if start-2000 > 0 {
|
||||
start = start - 2000
|
||||
}
|
||||
end := idx[1] + 8000
|
||||
if end > len(html) {
|
||||
end = len(html)
|
||||
}
|
||||
snippet := html[start:end]
|
||||
|
||||
vi := VideoItem{VideoID: videoID}
|
||||
vi.ThumbnailURL = fmt.Sprintf("https://img.youtube.com/vi/%s/maxresdefault.jpg", videoID)
|
||||
|
||||
// Extract metadata
|
||||
if m := regexp.MustCompile(`"title":\{"runs":\[\{"text":"([^"]+)"`).FindStringSubmatch(snippet); len(m) >= 2 {
|
||||
vi.Title = unescapeYT(m[1])
|
||||
} else if m := regexp.MustCompile(`"title":\{"simpleText":"([^"]+)"`).FindStringSubmatch(snippet); len(m) >= 2 {
|
||||
vi.Title = unescapeYT(m[1])
|
||||
}
|
||||
|
||||
if m := regexp.MustCompile(`"lengthText":\{[^}]*"simpleText":"([^"]+)"`).FindStringSubmatch(snippet); len(m) >= 2 {
|
||||
vi.Length = m[1]
|
||||
}
|
||||
|
||||
if m := regexp.MustCompile(`"publishedTimeText":\{"simpleText":"([^"]+)"`).FindStringSubmatch(snippet); len(m) >= 2 {
|
||||
vi.PublishedText = m[1]
|
||||
vi.PublishedDate = parseRelativeToISO(m[1])
|
||||
}
|
||||
|
||||
if m := regexp.MustCompile(`"viewCountText":\{"simpleText":"([^"]+)"`).FindStringSubmatch(snippet); len(m) >= 2 {
|
||||
vi.ViewsText = m[1]
|
||||
vi.Views = parseCountText(m[1])
|
||||
}
|
||||
|
||||
videos = append(videos, vi)
|
||||
}
|
||||
|
||||
// Extract channel info
|
||||
channelDisplay := handle
|
||||
if m := regexp.MustCompile(`"canonicalBaseUrl":"\\/(@[^\"]+)"`).FindStringSubmatch(html); len(m) >= 2 {
|
||||
channelDisplay = m[1]
|
||||
}
|
||||
|
||||
subText := ""
|
||||
if m := regexp.MustCompile(`"subscriberCountText":\{"simpleText":"([^"]+)"`).FindStringSubmatch(html); len(m) >= 2 {
|
||||
subText = m[1]
|
||||
}
|
||||
subs := parseCountText(subText)
|
||||
|
||||
return ChannelVideosResponse{
|
||||
Channel: channelDisplay,
|
||||
ChannelURL: channelURL,
|
||||
SubscribersText: subText,
|
||||
Subscribers: subs,
|
||||
Videos: videos,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IntegratedVideoInfo represents the extracted video information
|
||||
type IntegratedVideoInfo struct {
|
||||
VideoID string `json:"video_id"`
|
||||
Title string `json:"title"`
|
||||
Channel string `json:"channel"`
|
||||
Thumbnail string `json:"thumbnail_url"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// GetVideoDetailsIntegrated scrapes individual video details
|
||||
func (y *YouTubeIntegratedService) GetVideoDetailsIntegrated(videoURL string) (IntegratedVideoInfo, error) {
|
||||
videoID := extractVideoID(videoURL)
|
||||
if videoID == "" {
|
||||
return IntegratedVideoInfo{
|
||||
Success: false,
|
||||
Error: "Invalid YouTube URL",
|
||||
}, nil
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return IntegratedVideoInfo{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Failed to create request: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
|
||||
|
||||
resp, err := y.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return IntegratedVideoInfo{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Failed to fetch page: %v", err),
|
||||
}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return IntegratedVideoInfo{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("HTTP %d", resp.StatusCode),
|
||||
}, nil
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
return IntegratedVideoInfo{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Failed to parse HTML: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
title := ""
|
||||
channel := ""
|
||||
|
||||
doc.Find("title").Each(func(i int, s *goquery.Selection) {
|
||||
if title == "" {
|
||||
title = s.Text()
|
||||
title = strings.TrimSuffix(title, " - YouTube")
|
||||
}
|
||||
})
|
||||
|
||||
doc.Find("a.yt-simple-endpoint.style-scope.yt-formatted-string").Each(func(i int, s *goquery.Selection) {
|
||||
if channel == "" && strings.Contains(s.AttrOr("href", ""), "/@") {
|
||||
channel = s.Text()
|
||||
}
|
||||
})
|
||||
|
||||
if title == "" {
|
||||
title = "Video " + videoID
|
||||
}
|
||||
|
||||
return IntegratedVideoInfo{
|
||||
VideoID: videoID,
|
||||
Title: title,
|
||||
Channel: channel,
|
||||
Thumbnail: fmt.Sprintf("https://img.youtube.com/vi/%s/maxresdefault.jpg", videoID),
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func normalizeChannelInput(input string) (handle string, url string) {
|
||||
in := strings.TrimSpace(input)
|
||||
lower := strings.ToLower(in)
|
||||
isURL := strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") || strings.HasPrefix(lower, "www.") || strings.HasPrefix(lower, "youtube.com/")
|
||||
|
||||
if isURL {
|
||||
if strings.HasPrefix(lower, "www.") || strings.HasPrefix(lower, "youtube.com/") {
|
||||
in = "https://" + strings.TrimPrefix(in, "www.")
|
||||
if !strings.HasPrefix(strings.ToLower(in), "https://youtube.com/") && !strings.HasPrefix(strings.ToLower(in), "https://www.youtube.com/") {
|
||||
in = "https://www." + strings.TrimPrefix(in, "https://")
|
||||
}
|
||||
}
|
||||
in = strings.ReplaceAll(in, "m.youtube.com", "www.youtube.com")
|
||||
|
||||
reHandle := regexp.MustCompile(`https?://(www\.)?youtube\.com/(@[^/]+)`)
|
||||
if m := reHandle.FindStringSubmatch(in); len(m) >= 3 {
|
||||
handle = m[2]
|
||||
} else {
|
||||
rePath := regexp.MustCompile(`https?://(www\.)?youtube\.com/([^/?#]+)`)
|
||||
if m2 := rePath.FindStringSubmatch(in); len(m2) >= 3 {
|
||||
seg := m2[2]
|
||||
if strings.HasPrefix(seg, "@") {
|
||||
handle = seg
|
||||
} else {
|
||||
handle = "@" + seg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(strings.ToLower(in), "/videos") || strings.Contains(strings.ToLower(in), "/shorts") || strings.Contains(strings.ToLower(in), "/streams") {
|
||||
url = in
|
||||
} else {
|
||||
if handle == "" {
|
||||
url = in
|
||||
} else {
|
||||
url = fmt.Sprintf("https://www.youtube.com/%s/videos", handle)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if strings.HasPrefix(in, "@") {
|
||||
handle = in
|
||||
} else {
|
||||
handle = "@" + in
|
||||
}
|
||||
url = fmt.Sprintf("https://www.youtube.com/%s/videos", handle)
|
||||
}
|
||||
|
||||
if handle == "" {
|
||||
handle = in
|
||||
if !strings.HasPrefix(handle, "@") {
|
||||
handle = "@" + handle
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func unescapeYT(s string) string {
|
||||
s = strings.ReplaceAll(s, `\/`, `/`)
|
||||
s = strings.ReplaceAll(s, `\u0026`, `&`)
|
||||
return s
|
||||
}
|
||||
|
||||
func parseRelativeToISO(rel string) string {
|
||||
now := time.Now()
|
||||
lower := strings.ToLower(rel)
|
||||
re := regexp.MustCompile(`(\d+)[\s-]*(second|minute|hour|day|week|month|year)s?\s+ago`)
|
||||
if m := re.FindStringSubmatch(lower); len(m) >= 3 {
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
unit := m[2]
|
||||
switch unit {
|
||||
case "second":
|
||||
return now.Add(-time.Duration(n) * time.Second).Format("2006-01-02")
|
||||
case "minute":
|
||||
return now.Add(-time.Duration(n) * time.Minute).Format("2006-01-02")
|
||||
case "hour":
|
||||
return now.Add(-time.Duration(n) * time.Hour).Format("2006-01-02")
|
||||
case "day":
|
||||
return now.AddDate(0, 0, -n).Format("2006-01-02")
|
||||
case "week":
|
||||
return now.AddDate(0, 0, -7*n).Format("2006-01-02")
|
||||
case "month":
|
||||
return now.AddDate(0, -n, 0).Format("2006-01-02")
|
||||
case "year":
|
||||
return now.AddDate(-n, 0, 0).Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseCountText(s string) int64 {
|
||||
t := strings.ToLower(strings.TrimSpace(s))
|
||||
re := regexp.MustCompile(`([0-9]+(?:\.[0-9]+)?)([kmb])?`)
|
||||
if m := re.FindStringSubmatch(t); len(m) >= 2 {
|
||||
numStr := m[1]
|
||||
suf := ""
|
||||
if len(m) >= 3 {
|
||||
suf = m[2]
|
||||
}
|
||||
f, err := strconv.ParseFloat(numStr, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
switch suf {
|
||||
case "k":
|
||||
f *= 1_000
|
||||
case "m":
|
||||
f *= 1_000_000
|
||||
case "b":
|
||||
f *= 1_000_000_000
|
||||
}
|
||||
return int64(f)
|
||||
}
|
||||
digits := regexp.MustCompile(`[^0-9]`).ReplaceAllString(t, "")
|
||||
if digits == "" {
|
||||
return 0
|
||||
}
|
||||
v, _ := strconv.ParseInt(digits, 10, 64)
|
||||
return v
|
||||
}
|
||||
|
||||
func extractVideoID(url string) string {
|
||||
if strings.Contains(url, "youtu.be/") {
|
||||
parts := strings.Split(url, "youtu.be/")
|
||||
if len(parts) > 1 {
|
||||
return strings.Split(parts[1], "?")[0]
|
||||
}
|
||||
} else if strings.Contains(url, "youtube.com/watch") {
|
||||
parts := strings.Split(url, "v=")
|
||||
if len(parts) > 1 {
|
||||
return strings.Split(parts[1], "&")[0]
|
||||
}
|
||||
} else if strings.Contains(url, "youtube.com/embed/") {
|
||||
parts := strings.Split(url, "embed/")
|
||||
if len(parts) > 1 {
|
||||
return strings.Split(parts[1], "?")[0]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Global integrated service instance
|
||||
var integratedYouTubeService = NewYouTubeIntegratedService()
|
||||
|
||||
// Integrated service functions for backward compatibility
|
||||
func SearchYouTubeVideosIntegrated(query string, maxResults int) (*YouTubeSearchResponse, error) {
|
||||
// Always use real YouTube search - no more demo mode mock data
|
||||
if maxResults <= 0 || maxResults > 9 {
|
||||
maxResults = 9
|
||||
}
|
||||
|
||||
videos, err := integratedYouTubeService.SearchVideosIntegrated(query, maxResults)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ytVideos []YouTubeVideo
|
||||
for _, video := range videos {
|
||||
ytVideo := YouTubeVideo{
|
||||
ID: video.VideoID,
|
||||
Title: video.Title,
|
||||
Thumbnail: video.Thumbnail,
|
||||
ViewCount: 0,
|
||||
PublishedAt: "",
|
||||
ChannelTitle: video.ChannelName,
|
||||
}
|
||||
ytVideos = append(ytVideos, ytVideo)
|
||||
}
|
||||
|
||||
return &YouTubeSearchResponse{
|
||||
Videos: ytVideos,
|
||||
TotalResults: len(ytVideos),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func GetYouTubeChannelVideosIntegrated(channelID string, maxResults int) (*YouTubeSearchResponse, error) {
|
||||
// Always use real YouTube channel service - no more demo mode mock data
|
||||
response, err := integratedYouTubeService.GetChannelVideosIntegrated(channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var videos []YouTubeVideo
|
||||
for _, video := range response.Videos {
|
||||
ytVideo := YouTubeVideo{
|
||||
ID: video.VideoID,
|
||||
Title: video.Title,
|
||||
Thumbnail: video.ThumbnailURL,
|
||||
Duration: video.Length,
|
||||
ViewCount: video.Views,
|
||||
PublishedAt: video.PublishedDate,
|
||||
ChannelTitle: response.Channel,
|
||||
}
|
||||
videos = append(videos, ytVideo)
|
||||
}
|
||||
|
||||
if len(videos) > maxResults {
|
||||
videos = videos[:maxResults]
|
||||
}
|
||||
|
||||
return &YouTubeSearchResponse{
|
||||
Videos: videos,
|
||||
TotalResults: len(videos),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// GetEncryptionKey returns the encryption key from environment
|
||||
func GetEncryptionKey() ([]byte, error) {
|
||||
key := os.Getenv("ENCRYPTION_KEY")
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("ENCRYPTION_KEY environment variable not set")
|
||||
}
|
||||
|
||||
// Hash the key to ensure it's exactly 32 bytes for AES-256
|
||||
hash := sha256.Sum256([]byte(key))
|
||||
return hash[:], nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts plaintext using AES-GCM
|
||||
func Encrypt(plaintext string) (string, error) {
|
||||
key, err := GetEncryptionKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts ciphertext using AES-GCM
|
||||
func Decrypt(ciphertext string) (string, error) {
|
||||
key, err := GetEncryptionKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return "", fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertext_bytes := data[:nonceSize], data[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext_bytes, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// EncryptFile encrypts file content and returns the encrypted data
|
||||
func EncryptFile(content []byte) ([]byte, error) {
|
||||
key, err := GetEncryptionKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Prepend nonce to encrypted content
|
||||
encrypted := gcm.Seal(nonce, nonce, content, nil)
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
// DecryptFile decrypts file content
|
||||
func DecryptFile(encryptedContent []byte) ([]byte, error) {
|
||||
key, err := GetEncryptionKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(encryptedContent) < nonceSize {
|
||||
return nil, fmt.Errorf("encrypted content too short")
|
||||
}
|
||||
|
||||
nonce, ciphertext := encryptedContent[:nonceSize], encryptedContent[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// IsEncrypted checks if content appears to be encrypted (base64 encoded)
|
||||
func IsEncrypted(content string) bool {
|
||||
// Simple check: try to base64 decode and see if it looks like encrypted content
|
||||
_, err := base64.StdEncoding.DecodeString(content)
|
||||
return err == nil && len(content) > 32 // Encrypted content should be longer than 32 chars
|
||||
}
|
||||
|
||||
// GenerateEncryptionKey generates a new random encryption key
|
||||
func GenerateEncryptionKey() (string, error) {
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(key), nil
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// APIResponse represents a standard API response
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
}
|
||||
|
||||
// PaginatedResponse represents a paginated API response
|
||||
type PaginatedResponse struct {
|
||||
APIResponse
|
||||
Pagination PaginationInfo `json:"pagination"`
|
||||
}
|
||||
|
||||
// PaginationInfo contains pagination metadata
|
||||
type PaginationInfo struct {
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"per_page"`
|
||||
Total int64 `json:"total"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
HasNext bool `json:"has_next"`
|
||||
HasPrev bool `json:"has_prev"`
|
||||
}
|
||||
|
||||
// Success sends a successful response
|
||||
func Success(c *gin.Context, data interface{}, message ...string) {
|
||||
msg := "Operation successful"
|
||||
if len(message) > 0 {
|
||||
msg = message[0]
|
||||
}
|
||||
|
||||
response := APIResponse{
|
||||
Success: true,
|
||||
Data: data,
|
||||
Message: msg,
|
||||
Timestamp: time.Now(),
|
||||
RequestID: c.GetString("RequestID"),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Error sends an error response
|
||||
func Error(c *gin.Context, statusCode int, err error, message ...string) {
|
||||
msg := "An error occurred"
|
||||
if len(message) > 0 {
|
||||
msg = message[0]
|
||||
}
|
||||
|
||||
response := APIResponse{
|
||||
Success: false,
|
||||
Message: msg,
|
||||
Error: err.Error(),
|
||||
Timestamp: time.Now(),
|
||||
RequestID: c.GetString("RequestID"),
|
||||
}
|
||||
|
||||
c.JSON(statusCode, response)
|
||||
}
|
||||
|
||||
// ValidationError sends a validation error response
|
||||
func ValidationError(c *gin.Context, errors interface{}) {
|
||||
response := APIResponse{
|
||||
Success: false,
|
||||
Message: "Validation failed",
|
||||
Error: "Invalid input data",
|
||||
Data: errors,
|
||||
Timestamp: time.Now(),
|
||||
RequestID: c.GetString("RequestID"),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusBadRequest, response)
|
||||
}
|
||||
|
||||
// Paginated sends a paginated response
|
||||
func Paginated(c *gin.Context, data interface{}, pagination PaginationInfo, message ...string) {
|
||||
msg := "Data retrieved successfully"
|
||||
if len(message) > 0 {
|
||||
msg = message[0]
|
||||
}
|
||||
|
||||
response := PaginatedResponse{
|
||||
APIResponse: APIResponse{
|
||||
Success: true,
|
||||
Data: data,
|
||||
Message: msg,
|
||||
Timestamp: time.Now(),
|
||||
RequestID: c.GetString("RequestID"),
|
||||
},
|
||||
Pagination: pagination,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// CalculatePagination calculates pagination information
|
||||
func CalculatePagination(page, perPage int, total int64) PaginationInfo {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 10
|
||||
}
|
||||
if perPage > 100 {
|
||||
perPage = 100
|
||||
}
|
||||
|
||||
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
|
||||
hasNext := page < totalPages
|
||||
hasPrev := page > 1
|
||||
|
||||
return PaginationInfo{
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
Total: total,
|
||||
TotalPages: totalPages,
|
||||
HasNext: hasNext,
|
||||
HasPrev: hasPrev,
|
||||
}
|
||||
}
|
||||
|
||||
// Created sends a created response
|
||||
func Created(c *gin.Context, data interface{}, message ...string) {
|
||||
msg := "Resource created successfully"
|
||||
if len(message) > 0 {
|
||||
msg = message[0]
|
||||
}
|
||||
|
||||
response := APIResponse{
|
||||
Success: true,
|
||||
Data: data,
|
||||
Message: msg,
|
||||
Timestamp: time.Now(),
|
||||
RequestID: c.GetString("RequestID"),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, response)
|
||||
}
|
||||
|
||||
// Updated sends an updated response
|
||||
func Updated(c *gin.Context, data interface{}, message ...string) {
|
||||
msg := "Resource updated successfully"
|
||||
if len(message) > 0 {
|
||||
msg = message[0]
|
||||
}
|
||||
|
||||
response := APIResponse{
|
||||
Success: true,
|
||||
Data: data,
|
||||
Message: msg,
|
||||
Timestamp: time.Now(),
|
||||
RequestID: c.GetString("RequestID"),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Deleted sends a deleted response
|
||||
func Deleted(c *gin.Context, message ...string) {
|
||||
msg := "Resource deleted successfully"
|
||||
if len(message) > 0 {
|
||||
msg = message[0]
|
||||
}
|
||||
|
||||
response := APIResponse{
|
||||
Success: true,
|
||||
Message: msg,
|
||||
Timestamp: time.Now(),
|
||||
RequestID: c.GetString("RequestID"),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GenerateSecureSecret generates a cryptographically secure random secret
|
||||
func GenerateSecureSecret(byteLength int) (string, error) {
|
||||
bytes := make([]byte, byteLength)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// GenerateSecureKey generates a hex-encoded encryption key
|
||||
func GenerateSecureKey(bitLength int) (string, error) {
|
||||
byteLength := bitLength / 8
|
||||
bytes := make([]byte, byteLength)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// GetOrCreateJWTSecret retrieves JWT secret from file or generates a new one
|
||||
func GetOrCreateJWTSecret() (string, error) {
|
||||
secretFile := "jwt_secret.key"
|
||||
|
||||
// Try to read existing secret
|
||||
if secret, err := readSecretFromFile(secretFile); err == nil {
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// Generate new secret
|
||||
secret, err := GenerateSecureSecret(32) // 32 bytes = 256 bits
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate JWT secret: %w", err)
|
||||
}
|
||||
|
||||
// Save to file
|
||||
if err := saveSecretToFile(secretFile, secret); err != nil {
|
||||
return "", fmt.Errorf("failed to save JWT secret: %w", err)
|
||||
}
|
||||
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// GetOrCreateEncryptionKey retrieves encryption key from file or generates a new one
|
||||
func GetOrCreateEncryptionKey() (string, error) {
|
||||
keyFile := "encryption.key"
|
||||
|
||||
// Try to read existing key
|
||||
if key, err := readSecretFromFile(keyFile); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Generate new key
|
||||
key, err := GenerateSecureKey(256) // 256 bits
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate encryption key: %w", err)
|
||||
}
|
||||
|
||||
// Save to file
|
||||
if err := saveSecretToFile(keyFile, key); err != nil {
|
||||
return "", fmt.Errorf("failed to save encryption key: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// readSecretFromFile reads a secret from a file
|
||||
func readSecretFromFile(filename string) (string, error) {
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
secret := string(data)
|
||||
if secret == "" {
|
||||
return "", fmt.Errorf("empty secret file")
|
||||
}
|
||||
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// saveSecretToFile saves a secret to a file with secure permissions
|
||||
func saveSecretToFile(filename, secret string) error {
|
||||
// Create the file with restricted permissions (only readable by owner)
|
||||
file, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = file.WriteString(secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateSecretStrength checks if a secret meets minimum security requirements
|
||||
func ValidateSecretStrength(secret string, minLength int) error {
|
||||
if len(secret) < minLength {
|
||||
return fmt.Errorf("secret too short: minimum %d characters required", minLength)
|
||||
}
|
||||
|
||||
// Check entropy (basic check)
|
||||
entropy := calculateEntropy(secret)
|
||||
if entropy < 3.0 { // Minimum entropy threshold
|
||||
return fmt.Errorf("secret has low entropy: %.2f (minimum 3.0)", entropy)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// calculateEntropy calculates the Shannon entropy of a string
|
||||
func calculateEntropy(s string) float64 {
|
||||
if len(s) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Count character frequencies
|
||||
freq := make(map[rune]int)
|
||||
for _, char := range s {
|
||||
freq[char]++
|
||||
}
|
||||
|
||||
// Calculate entropy
|
||||
entropy := 0.0
|
||||
length := float64(len(s))
|
||||
|
||||
for _, count := range freq {
|
||||
if count > 0 {
|
||||
p := float64(count) / length
|
||||
entropy -= p * log2(p)
|
||||
}
|
||||
}
|
||||
|
||||
return entropy
|
||||
}
|
||||
|
||||
// log2 calculates base-2 logarithm
|
||||
func log2(x float64) float64 {
|
||||
const ln2 = 0.6931471805599453 // ln(2)
|
||||
return 1.0 / ln2 * logNatural(x)
|
||||
}
|
||||
|
||||
// logNatural calculates natural logarithm using approximation
|
||||
func logNatural(x float64) float64 {
|
||||
if x <= 0 {
|
||||
return 0
|
||||
}
|
||||
if x == 1 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Simple approximation for ln(x)
|
||||
// For production, use math.Log from the standard library
|
||||
n := 0.0
|
||||
for x > 1.0 {
|
||||
x /= 2.718281828459045 // e
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// RotateSecret generates a new secret and updates the file
|
||||
func RotateSecret(filename string) (string, error) {
|
||||
// Generate new secret
|
||||
var newSecret string
|
||||
var err error
|
||||
|
||||
if filename == "jwt_secret.key" {
|
||||
newSecret, err = GenerateSecureSecret(32)
|
||||
} else if filename == "encryption.key" {
|
||||
newSecret, err = GenerateSecureKey(256)
|
||||
} else {
|
||||
return "", fmt.Errorf("unknown secret file type: %s", filename)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate new secret: %w", err)
|
||||
}
|
||||
|
||||
// Backup old secret if it exists
|
||||
if _, err := os.Stat(filename); err == nil {
|
||||
backupFile := fmt.Sprintf("%s.backup.%d", filename, time.Now().Unix())
|
||||
if err := os.Rename(filename, backupFile); err != nil {
|
||||
return "", fmt.Errorf("failed to backup old secret: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save new secret
|
||||
if err := saveSecretToFile(filename, newSecret); err != nil {
|
||||
return "", fmt.Errorf("failed to save new secret: %w", err)
|
||||
}
|
||||
|
||||
return newSecret, nil
|
||||
}
|
||||
|
||||
// GetSecretFilePath returns the full path to a secret file
|
||||
func GetSecretFilePath(filename string) string {
|
||||
// Store secrets in a secure directory
|
||||
secretDir := os.Getenv("SECRET_DIR")
|
||||
if secretDir == "" {
|
||||
secretDir = "./secrets"
|
||||
}
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if err := os.MkdirAll(secretDir, 0700); err != nil {
|
||||
// Fallback to current directory
|
||||
return filename
|
||||
}
|
||||
|
||||
return filepath.Join(secretDir, filename)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Validator provides common validation functions
|
||||
type Validator struct {
|
||||
errors map[string]string
|
||||
}
|
||||
|
||||
// NewValidator creates a new validator instance
|
||||
func NewValidator() *Validator {
|
||||
return &Validator{
|
||||
errors: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// Required checks if a field is not empty
|
||||
func (v *Validator) Required(field, value string) *Validator {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
v.errors[field] = fmt.Sprintf("%s is required", field)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// MinLength checks if a field meets minimum length
|
||||
func (v *Validator) MinLength(field, value string, min int) *Validator {
|
||||
if utf8.RuneCountInString(value) < min {
|
||||
v.errors[field] = fmt.Sprintf("%s must be at least %d characters", field, min)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// MaxLength checks if a field exceeds maximum length
|
||||
func (v *Validator) MaxLength(field, value string, max int) *Validator {
|
||||
if utf8.RuneCountInString(value) > max {
|
||||
v.errors[field] = fmt.Sprintf("%s must be at most %d characters", field, max)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Email checks if a field is a valid email
|
||||
func (v *Validator) Email(field, value string) *Validator {
|
||||
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||
if !emailRegex.MatchString(value) {
|
||||
v.errors[field] = fmt.Sprintf("%s must be a valid email address", field)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// URL checks if a field is a valid URL
|
||||
func (v *Validator) URL(field, value string) *Validator {
|
||||
urlRegex := regexp.MustCompile(`^https?://[^\s/$.?#].[^\s]*$`)
|
||||
if !urlRegex.MatchString(value) {
|
||||
v.errors[field] = fmt.Sprintf("%s must be a valid URL", field)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Match checks if a field matches a regex pattern
|
||||
func (v *Validator) Match(field, value, pattern, message string) *Validator {
|
||||
regex := regexp.MustCompile(pattern)
|
||||
if !regex.MatchString(value) {
|
||||
v.errors[field] = message
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// In checks if a field value is in the allowed values
|
||||
func (v *Validator) In(field, value string, allowed []string) *Validator {
|
||||
for _, allowedValue := range allowed {
|
||||
if value == allowedValue {
|
||||
return v
|
||||
}
|
||||
}
|
||||
v.errors[field] = fmt.Sprintf("%s must be one of: %s", field, strings.Join(allowed, ", "))
|
||||
return v
|
||||
}
|
||||
|
||||
// HasErrors returns true if there are validation errors
|
||||
func (v *Validator) HasErrors() bool {
|
||||
return len(v.errors) > 0
|
||||
}
|
||||
|
||||
// GetErrors returns all validation errors
|
||||
func (v *Validator) GetErrors() map[string]string {
|
||||
return v.errors
|
||||
}
|
||||
|
||||
// GetError returns a specific field error
|
||||
func (v *Validator) GetError(field string) string {
|
||||
return v.errors[field]
|
||||
}
|
||||
|
||||
// Clear clears all validation errors
|
||||
func (v *Validator) Clear() *Validator {
|
||||
v.errors = make(map[string]string)
|
||||
return v
|
||||
}
|
||||
|
||||
// ValidatePassword checks password strength
|
||||
func (v *Validator) ValidatePassword(field, password string) *Validator {
|
||||
if len(password) < 8 {
|
||||
v.errors[field] = "Password must be at least 8 characters long"
|
||||
return v
|
||||
}
|
||||
|
||||
hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
|
||||
hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
|
||||
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
|
||||
hasSpecial := regexp.MustCompile(`[!@#$%^&*(),.?":{}|<>]`).MatchString(password)
|
||||
|
||||
errorCount := 0
|
||||
if !hasUpper {
|
||||
errorCount++
|
||||
}
|
||||
if !hasLower {
|
||||
errorCount++
|
||||
}
|
||||
if !hasNumber {
|
||||
errorCount++
|
||||
}
|
||||
if !hasSpecial {
|
||||
errorCount++
|
||||
}
|
||||
|
||||
if errorCount > 1 {
|
||||
v.errors[field] = "Password must contain at least 3 of: uppercase, lowercase, number, special character"
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
# YouTube Scraper Service
|
||||
|
||||
A standalone microservice for scraping YouTube video data. This service runs independently from the main Trackeep application.
|
||||
|
||||
## Features
|
||||
|
||||
- **Mock YouTube Data**: Provides mock YouTube video data for development and testing
|
||||
- **Channel Videos**: Fetch videos from specific YouTube channels
|
||||
- **Search**: Search through YouTube video metadata
|
||||
- **REST API**: Simple REST endpoints for integration
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Health Check
|
||||
```
|
||||
GET /
|
||||
```
|
||||
Returns service status and information.
|
||||
|
||||
### Get Channel Videos
|
||||
```
|
||||
GET /channel_videos?channel={channel_name}
|
||||
```
|
||||
Fetches videos for a specific YouTube channel.
|
||||
|
||||
**Parameters:**
|
||||
- `channel`: YouTube channel name (e.g., "@Fireship", "@NetworkChuck")
|
||||
|
||||
### Search Videos
|
||||
```
|
||||
GET /search?q={query}
|
||||
```
|
||||
Searches through video titles, descriptions, and channel names.
|
||||
|
||||
**Parameters:**
|
||||
- `q`: Search query
|
||||
|
||||
## Running the Service
|
||||
|
||||
### Development
|
||||
```bash
|
||||
cd youtube-scraper
|
||||
go run .
|
||||
```
|
||||
|
||||
### Production
|
||||
```bash
|
||||
cd youtube-scraper
|
||||
go build -o youtube-scraper .
|
||||
./youtube-scraper
|
||||
```
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
docker build -f ../Dockerfile.youtube-scraper -t youtube-scraper ..
|
||||
docker run -p 7857:7857 youtube-scraper
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `PORT`: Service port (default: 7857)
|
||||
|
||||
## Mock Data
|
||||
|
||||
The service includes mock data for popular tech YouTube channels:
|
||||
- @Fireship
|
||||
- @NetworkChuck
|
||||
- @beyondfireship
|
||||
- @LinusTechTips
|
||||
- @Mrwhosetheboss
|
||||
- @JerryRigEverything
|
||||
- @JeffGeerling
|
||||
- @mkbhd
|
||||
|
||||
## Integration
|
||||
|
||||
This service is designed to be called by the main Trackeep application via HTTP requests. The main app can be configured to use this service for YouTube-related features.
|
||||
@@ -0,0 +1,32 @@
|
||||
module youtube-scraper
|
||||
|
||||
go 1.21
|
||||
|
||||
require github.com/gin-gonic/gin v1.9.1
|
||||
|
||||
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/json-iterator/go v1.1.12 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.9.0 // indirect
|
||||
golang.org/x/net v0.10.0 // indirect
|
||||
golang.org/x/sys v0.8.0 // indirect
|
||||
golang.org/x/text v0.9.0 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user