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 98089 additions and 2585 deletions
+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: "demo@trackeep.com",
})
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 == "demo@trackeep.com" && req.Password == "demo123" {
// Create demo user
demoUser := models.User{
ID: 1,
Email: "demo@trackeep.com",
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,
})
}