first test

This commit is contained in:
Tomas Dvorak
2026-02-08 14:14:55 +01:00
parent 18aa702174
commit d27cf14110
372 changed files with 98087 additions and 2583 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# Build stage
FROM golang:1.21-alpine AS builder
FROM golang:1.24-alpine AS builder
WORKDIR /app
+51
View File
@@ -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"]
BIN
View File
Binary file not shown.
+15 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+322
View File
@@ -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"})
}
+811
View File
@@ -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(&note).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")
}
}
+401
View File
@@ -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})
}
+388
View File
@@ -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
+383
View File
@@ -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
View File
@@ -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(&noteCount)
// 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(&notes)
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")
}
}
+508
View File
@@ -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
}
+329
View File
@@ -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})
}
+360
View File
@@ -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
}
+181
View File
@@ -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(&notes)
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])
}
+499
View File
@@ -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
}
+303
View File
@@ -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),
})
}
+15
View File
@@ -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(),
})
}
+435
View File
@@ -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(&note).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(&note).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(&note).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
}
}
+434
View File
@@ -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)
}
+568
View File
@@ -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)
}
+476
View File
@@ -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
}
+508
View File
@@ -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)
}
}
}
}
+388
View File
@@ -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})
}
+334
View File
@@ -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
}
+438
View File
@@ -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(&note).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)
}
+143
View File
@@ -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"
}
+71
View File
@@ -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"})
}
+662
View File
@@ -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(&notes).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)
}
+245
View File
@@ -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,
})
}
+523
View File
@@ -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(&notes).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",
})
}
+436
View File
@@ -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(&note, 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)
}
+341
View File
@@ -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,
})
}
+75
View File
@@ -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
+583
View File
@@ -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(&currentMember).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})
}
+328
View File
@@ -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})
}
+669
View File
@@ -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(&currentUser).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(&currentUser).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(&currentUser).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(&currentUser).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(&currentUser).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,
})
}
+395
View File
@@ -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)
})
}
+306
View File
@@ -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,
})
}
+782
View File
@@ -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)
}
+195
View File
@@ -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)
}
+145
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+414
View File
@@ -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)
}
}
+156
View File
@@ -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)
}
}
}
+57
View File
@@ -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"
}
+379
View File
@@ -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
}
-6
View File
@@ -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) {
+223
View File
@@ -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)
}
}
}
+58
View File
@@ -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)
}
}
}
+139
View File
@@ -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()
}
+235
View File
@@ -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()
}
}
+193
View File
@@ -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"`
}
+153
View File
@@ -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
}
+62
View File
@@ -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"`
}
+297
View File
@@ -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
}
+105
View File
@@ -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
}
+7 -7
View File
@@ -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"`
}
+170
View File
@@ -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
}
}
+56
View File
@@ -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"`
}
+444
View File
@@ -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
}
+69
View File
@@ -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
View File
@@ -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"`
}
+52
View File
@@ -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
}
+173
View File
@@ -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
}
+293
View File
@@ -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"`
}
+246
View File
@@ -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
}
+142
View File
@@ -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
}
+230
View File
@@ -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")
}
+88
View File
@@ -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
View File
@@ -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"`
}
+111
View File
@@ -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
}
+96
View File
@@ -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
}
+190
View File
@@ -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
}
+102
View File
@@ -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
View File
@@ -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"`
}
+27
View File
@@ -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"
}
+174
View File
@@ -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
}
+27
View File
@@ -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
View File
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
}
+532
View File
@@ -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
}
+461
View File
@@ -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
}
+328
View File
@@ -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
}
+228
View File
@@ -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
}
+277
View File
@@ -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
}
+613
View File
@@ -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(
"&nbsp;", " ",
"&amp;", "&",
"&quot;", `"`,
"&#39;", "'",
)
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
}
+190
View File
@@ -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
}
+198
View File
@@ -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)
}
+516
View File
@@ -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
}
+160
View File
@@ -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
}
+184
View File
@@ -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)
}
+225
View File
@@ -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)
}
+136
View File
@@ -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
}
+77
View File
@@ -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.
+32
View File
@@ -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
)
+86
View File
@@ -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