lots of stuff, don't truly remember
This commit is contained in:
155
TODO.md
155
TODO.md
@@ -4,37 +4,79 @@
|
||||
- [x] **User Registration & Login**: Email authentication with JWT tokens
|
||||
- [x] **User Profile**: Bio, stats, zones (HR/Power), equipment, FTP, weight
|
||||
- [x] **Password Recovery**: Email-based reset with secure tokens
|
||||
- [ ] **OAuth Integration**: Google, Apple, Strava, Garmin
|
||||
- [ ] **Onboarding & Baselines**: Guided setup, baseline tests, auto zone calc
|
||||
- [x] **Onboarding Wizard**: Guided setup with profile, metrics, equipment, and preferences steps
|
||||
- [~] **OAuth Integration**: Garmin & Wahoo OAuth implemented; Google, Apple, Strava pending
|
||||
- [ ] **Account Roles**: Athlete, Coach, Admin; team/org workspaces
|
||||
- [ ] **Multi-device Sessions**: Seamless handoff across web/mobile
|
||||
|
||||
## Workout Planning
|
||||
- [x] **Workout Scheduling**: Calendar view with monthly navigation, rich workout cards (duration, TSS, zone-colored segment charts, status borders)
|
||||
- [x] **Templates Library**: 11 built-in templates (Recovery, Endurance, Tempo, Sweet Spot, Threshold, Over-Unders, VO2max, Sprint, Ramp Test) with category browsing
|
||||
- [x] **Template Scheduling**: Browse and schedule templates directly from calendar Add Workout modal
|
||||
- [x] **Structured Workout Builder**: Interval builder with warmup/main/cooldown sections, power targets (%FTP), cadence, repeats, rest intervals, and live preview chart
|
||||
- [x] **Export Structured Workouts**: .zwo (Zwift), Garmin FIT/Workout, Wahoo
|
||||
- [ ] **AI-Powered Planning**: Generate plans by goal, time, fitness level
|
||||
- [ ] **Adaptive Scheduling**: Auto-reschedule based on missed sessions, fatigue, weather
|
||||
- [ ] **Workout Scheduling**: Calendar view, drag-drop, ICS sync (Google/Apple/Outlook)
|
||||
- [ ] **Goal Setting & Tracking**: SMART goals with real-time progress bars
|
||||
- [x] **Templates Library**: Plan & session templates (endurance, threshold, VO2, strength)
|
||||
- [x] **Export Structured Workouts**: .zwo (Zwift), Garmin FIT/Workout, Wahoo, TrainerRoad
|
||||
- [ ] **Race/Event Planner**: Target events, taper builder, gear checklist
|
||||
|
||||
## Workout Tracking
|
||||
- [ ] **Workout Logging**: Exercises, sets/reps/weight; power, HR, cadence, GPS
|
||||
- [x] **Workout CRUD**: Create, read, update, delete workouts with calendar integration
|
||||
- [x] **Device Capture**: File upload (FIT/TCX/GPX activity import with metric extraction)
|
||||
- [ ] **Tags & Notes**: RPE, mood, conditions, injuries, equipment used
|
||||
- [x] **Equipment Tracking**: Bike/components mileage auto-tracking, service reminders
|
||||
- [x] **ZWO Import**: Client-side Zwift workout file parsing
|
||||
- [x] **Equipment Tracking**: Bike/components mileage auto-tracking, service interval reminders, distance/duration since last service
|
||||
- [~] **Tags & Notes**: RPE & Notes fields implemented in backend; mood, conditions, injuries, and tag system still pending
|
||||
- [ ] **Drag-drop Calendar**: Reorder/reschedule workouts by dragging
|
||||
|
||||
## Workout Visualization
|
||||
- [x] **Workout Detail Modal**: Interval chart, auto-generated workout summary, zone distribution, completed metrics, equipment, Garmin/Wahoo push
|
||||
- [x] **Power Zone System**: 7-zone model (Recovery through Neuromuscular) with color-coded visualization
|
||||
- [x] **Mini Segment Charts**: Compact zone-colored bars on calendar cards
|
||||
- [x] **Zone Distribution**: Horizontal stacked bar with time-in-zone breakdown
|
||||
- [x] **TSS/IF Calculation**: Training Stress Score and Intensity Factor from segment data
|
||||
|
||||
## Dashboard
|
||||
- [x] **Stats Overview**: Total rides, distance, time, elevation gain
|
||||
- [x] **Calendar Widget**: Compact monthly calendar with workout cards, month navigation
|
||||
- [x] **Next Workout**: Upcoming workout display with link to calendar
|
||||
- [x] **Today's Progress**: Completion percentage for daily workouts
|
||||
- [x] **Profile Display**: Welcome message with first/last name from user profile
|
||||
- [ ] **Interactive Charts**: Activity trends (CTL/ATL/TSB), power curves
|
||||
|
||||
## Advanced Analytics
|
||||
- [ ] **Interactive Dashboards**: Charts for load (CTL/ATL/TSB), power curves, trends
|
||||
- [x] **Stats Page**: Summary, weekly, and monthly aggregated statistics
|
||||
- [x] **Personal Bests**: Personal records tracking
|
||||
- [ ] **Interactive Dashboards**: Charts for training load, power curves, trends
|
||||
- [ ] **Progress Insights (AI)**: Automatic highlights, plateau detection, anomaly alerts
|
||||
- [ ] **Comparisons**: Before/after, season-over-season, segment/time comparisons
|
||||
- [ ] **Custom Reports**: Export CSV/PDF; shareable report links
|
||||
|
||||
## Workout Library
|
||||
- [x] **Browse Library**: System and user workouts with search, category, type, and difficulty filters
|
||||
- [x] **Workout Detail Page**: Full workout view with interval chart and segment breakdown
|
||||
- [x] **Create Custom Workouts**: Interval builder with warmup/main/cooldown structure
|
||||
- [x] **My Workouts**: User's custom workout collection
|
||||
- [x] **Favorites**: Save and browse favorite workouts
|
||||
- [x] **Ratings**: Star rating system with comments
|
||||
- [ ] **Community Sharing**: Public/private workout sharing
|
||||
|
||||
## Training & Coaching
|
||||
- [x] **Structured Workouts**: Interval builder with targets (%FTP, cadence, repeats)
|
||||
- [ ] **Coaching & Guidance**: Coach portal, athlete assignments, plan reviews
|
||||
- [ ] **Virtual Training Rides**: Integrations with Zwift/Rouvy/RGT; video routes
|
||||
- [ ] **Structured Workouts**: Interval builder with targets (%FTP, %HRR, RPE)
|
||||
- [ ] **Messaging**: Coach–athlete chat, comments on sessions, file attachments
|
||||
- [ ] **Messaging**: Coach-athlete chat, comments on sessions, file attachments
|
||||
|
||||
## Integrations & Data
|
||||
- [x] **Garmin Connect**: OAuth2 PKCE flow, push workouts, connection management
|
||||
- [x] **Wahoo Cloud API**: OAuth2 flow, push workouts as plans, connection management
|
||||
- [x] **Activity Import**: FIT (session metrics), TCX (lap aggregation), GPX (Haversine distance, elevation)
|
||||
- [x] **Workout Export**: FIT (Garmin-compatible), ZWO (Zwift-compatible)
|
||||
- [ ] **Strava Sync**: OAuth login + activity sync
|
||||
- [ ] **TrainingPeaks**: Calendar + workout push
|
||||
- [x] **Intervals.icu**: Integration (push workouts + sync activities)
|
||||
- [ ] **COROS, Apple Health, Google Fit**: Wearable sync
|
||||
- [ ] **Music Integration**: Spotify/Apple Music workout-matched playlists
|
||||
- [ ] **Public API & Webhooks**: For partners, coaches, clubs
|
||||
|
||||
## Nutrition & Recovery
|
||||
- [ ] **Nutrition Planning**: Meal plans, macros, carb periodization
|
||||
@@ -54,13 +96,6 @@
|
||||
- [ ] **Personalized Recommendations (AI)**: Next best workout, videos, articles
|
||||
- [ ] **Rewards & Incentives**: Points store, partner discounts, raffles
|
||||
|
||||
## Integrations & Data
|
||||
- [~] **Wearable Sync**: Garmin, Wahoo, COROS, Apple Health, Google Fit (Garmin + Wahoo OAuth & push implemented)
|
||||
- [ ] **Platform Sync**: Strava, TrainingPeaks, Intervals.icu (calendar + workout push)
|
||||
- [ ] **Music Integration**: Spotify/Apple Music workout-matched playlists
|
||||
- [~] **Data Import/Export**: FIT/TCX/GPX activity import implemented; CSV/JSON export & bulk import pending
|
||||
- [ ] **Public API & Webhooks**: For partners, coaches, clubs
|
||||
|
||||
## Notifications & Comms
|
||||
- [ ] **Reminders**: Email, push, SMS; smart timing
|
||||
- [ ] **Digest Emails**: Weekly plan, monthly progress
|
||||
@@ -100,7 +135,7 @@
|
||||
|
||||
---
|
||||
|
||||
## Completed - Phase 1: Authentication & User Management ✅
|
||||
## Completed - Phase 1: Authentication & User Management
|
||||
|
||||
### Infrastructure
|
||||
- [x] Migrated from Python/Flask to Go with Chi router
|
||||
@@ -130,37 +165,27 @@
|
||||
|
||||
---
|
||||
|
||||
## Completed - Phase 2: User Profiles, Equipment & Workouts ✅
|
||||
## Completed - Phase 2: Profiles, Equipment & Workouts
|
||||
|
||||
### Profile & Equipment (completed earlier)
|
||||
### API Endpoints
|
||||
- [x] GET/PUT `/api/protected/profile` - Full profile management
|
||||
- [x] POST/GET/PUT/DELETE `/api/protected/equipment` - Bike/gear CRUD
|
||||
- [x] GET `/api/protected/zones` - Calculate HR & power training zones
|
||||
- [x] Equipment tracking (brand, model, weight)
|
||||
- [x] Equipment usage stats from workouts
|
||||
|
||||
### Workouts (completed earlier)
|
||||
- [x] POST/GET/PUT/DELETE `/api/protected/workouts` - Full workout CRUD
|
||||
- [x] GET `/api/protected/workouts/month` - Calendar month filtering
|
||||
- [x] GET `/api/protected/workout-types` - Predefined workout types
|
||||
- [x] POST `/api/protected/workouts/upload` - ZWO file import & parsing
|
||||
- [x] Structured workout segments (JSONB) with power/cadence targets
|
||||
|
||||
### Stats
|
||||
- [x] GET `/api/protected/stats/summary` - Overall ride statistics
|
||||
- [x] GET `/api/protected/stats/weekly` - Weekly aggregated stats
|
||||
- [x] GET `/api/protected/stats/monthly` - Monthly aggregated stats
|
||||
- [x] GET `/api/protected/stats/personal-bests` - Personal records
|
||||
|
||||
### Workout Templates
|
||||
- [x] GET `/api/protected/workout-templates` - List predefined templates (with category filter)
|
||||
- [x] GET `/api/protected/workout-templates/detail` - Get template with full segment data
|
||||
- [x] GET `/api/protected/workout-templates` - List templates (with category filter)
|
||||
- [x] GET `/api/protected/workout-templates/detail` - Template with full segments
|
||||
- [x] POST `/api/protected/workouts/from-template` - Create workout from template
|
||||
- [x] 11 built-in templates: Recovery, Endurance, Tempo, Sweet Spot, Threshold, Over-Unders, VO2max, Sprint, Ramp Test
|
||||
|
||||
---
|
||||
|
||||
## Completed - Phase 2.5: Workout Export & Device Integration ✅
|
||||
## Completed - Phase 2.5: Export & Device Integration
|
||||
|
||||
### Workout Export
|
||||
- [x] GET `/api/protected/workouts/export/fit` - FIT workout file export (Garmin-compatible)
|
||||
@@ -191,7 +216,7 @@
|
||||
|
||||
---
|
||||
|
||||
## Completed - Phase 2.6: Activity Import & Equipment Mileage ✅
|
||||
## Completed - Phase 2.6: Activity Import & Equipment Mileage
|
||||
|
||||
### Activity File Import (FIT/TCX/GPX)
|
||||
- [x] POST `/api/protected/workouts/import` - Import activity files (multipart upload)
|
||||
@@ -209,11 +234,60 @@
|
||||
- [x] Distance and duration since last service counters
|
||||
- [x] POST `/api/protected/equipment/service` - Record service (resets counters)
|
||||
- [x] GET `/api/protected/equipment/service-status` - Check if equipment needs servicing
|
||||
- [x] Service status in GET `/api/protected/equipment` response (total_distance, total_rides, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Next Phase: Phase 3 - OAuth Login & Platform Sync
|
||||
## Completed - Phase 3: Frontend & Full-Stack Features
|
||||
|
||||
### Vue 3 Frontend (Tailwind CSS v4)
|
||||
- [x] Vue 3 Composition API with `<script setup>`, Pinia stores, Vue Router 4
|
||||
- [x] Responsive dark/light mode UI with Tailwind CSS
|
||||
- [x] Docker/Podman containerized with Nginx serving
|
||||
|
||||
### Dashboard
|
||||
- [x] Stats overview cards (rides, distance, time, elevation)
|
||||
- [x] Compact calendar widget with monthly navigation and workout cards
|
||||
- [x] Next workout display with today's progress bar
|
||||
- [x] Profile-aware welcome message (first/last name)
|
||||
- [x] Workout detail modal from calendar clicks
|
||||
|
||||
### Calendar & Workout Management
|
||||
- [x] Full monthly calendar with TrainingPeaks-style workout cards
|
||||
- [x] Rich cards: duration, TSS badge, zone-colored mini segment chart, title, status borders
|
||||
- [x] Add Workout modal with Manual and From Template tabs
|
||||
- [x] Template browser with category filtering, detail preview (IntervalDisplay), and direct scheduling
|
||||
- [x] Workout builder (IntervalBuilder) in manual tab with warmup/main/cooldown, power targets, cadence, repeats, presets, live chart preview
|
||||
- [x] Structure-to-segments conversion for API submission
|
||||
- [x] File upload support (FIT/TCX/GPX activity import, ZWO workout import)
|
||||
|
||||
### Workout Detail & Visualization
|
||||
- [x] IntervalDisplay chart (section-colored bars, height = power, width = time)
|
||||
- [x] Auto-generated workout summary text (detects repeat patterns)
|
||||
- [x] Zone distribution (stacked bar + time-in-zone legend)
|
||||
- [x] MiniSegmentChart for calendar cells
|
||||
- [x] 7-zone power model with hex color mapping
|
||||
- [x] TSS and IF calculation from segments
|
||||
- [x] Garmin/Wahoo push buttons with connection status
|
||||
|
||||
### Workout Library
|
||||
- [x] Browse system + user workouts with search, filters (type, category, difficulty)
|
||||
- [x] Workout detail page with interval chart
|
||||
- [x] Create/edit custom workouts with interval builder
|
||||
- [x] My Workouts and Favorites collections
|
||||
- [x] Star rating with comments
|
||||
|
||||
### Other Pages
|
||||
- [x] Equipment management with service tracking, mileage display, record service
|
||||
- [x] Training zones page (HR & power zones)
|
||||
- [x] Stats page (summary, weekly, monthly, personal bests)
|
||||
- [x] Templates browser page with category filter and scheduling
|
||||
- [x] Onboarding wizard (profile, metrics, equipment, preferences steps)
|
||||
- [x] Settings: Garmin/Wahoo connections page with OAuth flow
|
||||
- [x] User profile editor
|
||||
|
||||
---
|
||||
|
||||
## Next Phase: Phase 4 - OAuth Login & Platform Sync
|
||||
|
||||
### OAuth Login
|
||||
- [ ] Google OAuth 2.0 (sign in with Google)
|
||||
@@ -223,4 +297,9 @@
|
||||
### Platform Sync
|
||||
- [ ] Strava activity sync (import completed rides)
|
||||
- [ ] TrainingPeaks calendar sync
|
||||
- [ ] Intervals.icu integration
|
||||
- [x] Intervals.icu integration (push workouts + sync activities)
|
||||
|
||||
### Calendar Enhancements
|
||||
- [ ] Drag-and-drop workout rescheduling
|
||||
- [ ] ICS sync (Google Calendar / Apple Calendar / Outlook)
|
||||
- [ ] Week view and day view options
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -11,12 +12,16 @@ import (
|
||||
"github.com/joho/godotenv"
|
||||
|
||||
"rideaware/internal/activity"
|
||||
"rideaware/internal/ai"
|
||||
"rideaware/internal/auth"
|
||||
"rideaware/internal/config"
|
||||
"rideaware/internal/equipment"
|
||||
"rideaware/internal/event"
|
||||
"rideaware/internal/export"
|
||||
"rideaware/internal/goal"
|
||||
"rideaware/internal/integration"
|
||||
"rideaware/internal/middleware"
|
||||
"rideaware/internal/nutrition"
|
||||
"rideaware/internal/stats"
|
||||
"rideaware/internal/templates"
|
||||
"rideaware/internal/user"
|
||||
@@ -41,6 +46,8 @@ func main() {
|
||||
&workout.Workout{},
|
||||
&integration.OAuthConnection{},
|
||||
&integration.OAuthState{},
|
||||
&ai.AIRecommendation{},
|
||||
&event.Event{},
|
||||
); err != nil {
|
||||
log.Fatalf("Failed to migrate database: %v", err)
|
||||
}
|
||||
@@ -155,6 +162,8 @@ func setupRoutes(r *chi.Mux) {
|
||||
r.Get("/workouts/equipment-stats", workoutHandler.GetEquipmentStats)
|
||||
r.Put("/workouts", workoutHandler.UpdateWorkout)
|
||||
r.Delete("/workouts", workoutHandler.DeleteWorkout)
|
||||
r.Post("/workouts/remove-duplicates", workoutHandler.RemoveDuplicates)
|
||||
r.Put("/workouts/reschedule", workoutHandler.RescheduleWorkout)
|
||||
r.Get("/workout-types", workoutHandler.GetWorkoutTypes)
|
||||
r.Post("/workouts/upload", workoutHandler.UploadWorkoutFile)
|
||||
|
||||
@@ -179,18 +188,65 @@ func setupRoutes(r *chi.Mux) {
|
||||
r.Get("/wahoo/status", wahooHandler.ConnectionStatus)
|
||||
r.Delete("/wahoo/disconnect", wahooHandler.Disconnect)
|
||||
|
||||
// Intervals.icu integration routes
|
||||
intervalsHandler := integration.NewIntervalsHandler()
|
||||
r.Post("/intervals/connect", intervalsHandler.SaveApiKey)
|
||||
r.Get("/intervals/status", intervalsHandler.ConnectionStatus)
|
||||
r.Delete("/intervals/disconnect", intervalsHandler.Disconnect)
|
||||
r.Post("/workouts/push/intervals", intervalsHandler.PushWorkout)
|
||||
r.Post("/intervals/sync", intervalsHandler.SyncActivities)
|
||||
|
||||
// Stats routes
|
||||
statsHandler := stats.NewHandler()
|
||||
r.Get("/stats/summary", statsHandler.GetSummary)
|
||||
r.Get("/stats/weekly", statsHandler.GetWeeklyStats)
|
||||
r.Get("/stats/monthly", statsHandler.GetMonthlyStats)
|
||||
r.Get("/stats/personal-bests", statsHandler.GetPersonalBests)
|
||||
r.Get("/stats/training-load", statsHandler.GetTrainingLoad)
|
||||
r.Get("/stats/power-history", statsHandler.GetPowerHistory)
|
||||
|
||||
// AI Training Plan routes
|
||||
aiHandler := ai.NewHandler()
|
||||
r.Post("/ai/generate", aiHandler.GenerateRecommendations)
|
||||
r.Post("/ai/schedule", aiHandler.ScheduleRecommendations)
|
||||
r.Get("/ai/history", aiHandler.GetRecommendationHistory)
|
||||
|
||||
// Event routes
|
||||
eventHandler := event.NewHandler()
|
||||
r.Post("/events", eventHandler.CreateEvent)
|
||||
r.Get("/events", eventHandler.GetEvents)
|
||||
r.Get("/events/upcoming", eventHandler.GetUpcomingEvents)
|
||||
r.Put("/events", eventHandler.UpdateEvent)
|
||||
r.Delete("/events", eventHandler.DeleteEvent)
|
||||
r.Get("/event-types", eventHandler.GetEventTypes)
|
||||
|
||||
// Nutrition routes
|
||||
nutritionHandler := nutrition.NewHandler()
|
||||
r.Get("/nutrition/targets", nutritionHandler.GetTargets)
|
||||
r.Get("/nutrition/weekly", nutritionHandler.GetWeekly)
|
||||
|
||||
// Workout template routes
|
||||
templateHandler := templates.NewHandler()
|
||||
r.Get("/workout-templates", templateHandler.ListTemplates)
|
||||
r.Get("/workout-templates/detail", templateHandler.GetTemplate)
|
||||
r.Post("/workouts/from-template", templateHandler.CreateFromTemplate)
|
||||
|
||||
// Goal routes
|
||||
goalHandler := goal.NewHandler()
|
||||
r.Post("/goals", goalHandler.CreateGoal)
|
||||
r.Get("/goals", goalHandler.GetGoals)
|
||||
r.Put("/goals", goalHandler.UpdateGoal)
|
||||
r.Delete("/goals", goalHandler.DeleteGoal)
|
||||
|
||||
// Admin-only routes (require 'admin' role in addition to valid JWT)
|
||||
r.Route("/admin", func(r chi.Router) {
|
||||
r.Use(authMiddleware.RequireRole("admin"))
|
||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "admin API operational",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
log.Println("✅ Routes registered successfully")
|
||||
|
||||
133
internal/ai/handler.go
Normal file
133
internal/ai/handler.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"rideaware/internal/config"
|
||||
"rideaware/internal/middleware"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler() *Handler {
|
||||
return &Handler{
|
||||
service: NewService(),
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateRecommendations POST /api/protected/ai/generate
|
||||
func (h *Handler) GenerateRecommendations(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
if claims == nil {
|
||||
respondError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req GenerateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if err := validateGenerateRequest(req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[AI] User %d requesting %d-day plan (focus: %v, intensity: %s)",
|
||||
claims.UserID, req.PlanDuration, req.FocusAreas, req.IntensityLevel)
|
||||
|
||||
// Generate workouts
|
||||
response, err := h.service.GenerateWorkouts(claims.UserID, req)
|
||||
if err != nil {
|
||||
log.Printf("[AI] Generation error for user %d: %v", claims.UserID, err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to generate workouts: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[AI] Successfully generated %d workouts for user %d (total TSS: %.1f)",
|
||||
len(response.Workouts), claims.UserID, response.TotalTSS)
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// ScheduleRecommendations POST /api/protected/ai/schedule
|
||||
func (h *Handler) ScheduleRecommendations(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
if claims == nil {
|
||||
respondError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
RecommendationID uint `json:"recommendation_id"`
|
||||
WorkoutIndices []int `json:"workout_indices"` // Which workouts to schedule
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
if req.RecommendationID == 0 {
|
||||
respondError(w, http.StatusBadRequest, "recommendation_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[AI] User %d scheduling %d workouts from recommendation %d",
|
||||
claims.UserID, len(req.WorkoutIndices), req.RecommendationID)
|
||||
|
||||
// Schedule selected workouts
|
||||
workouts, err := h.service.ScheduleWorkouts(claims.UserID, req.RecommendationID, req.WorkoutIndices)
|
||||
if err != nil {
|
||||
log.Printf("[AI] Schedule error for user %d: %v", claims.UserID, err)
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[AI] Successfully scheduled %d workouts for user %d", len(workouts), claims.UserID)
|
||||
|
||||
respondJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"scheduled_count": len(workouts),
|
||||
"workouts": workouts,
|
||||
})
|
||||
}
|
||||
|
||||
// GetRecommendationHistory GET /api/protected/ai/history
|
||||
func (h *Handler) GetRecommendationHistory(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
if claims == nil {
|
||||
respondError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
history, err := h.service.GetUserRecommendations(claims.UserID, 10)
|
||||
if err != nil {
|
||||
log.Printf("[AI] Failed to fetch history for user %d: %v", claims.UserID, err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to fetch history")
|
||||
return
|
||||
}
|
||||
|
||||
// Return empty array instead of null
|
||||
if history == nil {
|
||||
history = []AIRecommendation{}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, history)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func respondError(w http.ResponseWriter, status int, message string) {
|
||||
respondJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
170
internal/ai/model.go
Normal file
170
internal/ai/model.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"time"
|
||||
|
||||
"rideaware/internal/workout"
|
||||
)
|
||||
|
||||
// GenerateRequest is the user's request for AI workout generation
|
||||
type GenerateRequest struct {
|
||||
PlanDuration int `json:"plan_duration"` // Number of days (1, 7, 14, 28)
|
||||
FocusAreas []string `json:"focus_areas"` // ["endurance", "threshold", "vo2max", "recovery", "sprint", "sweet_spot"]
|
||||
IntensityLevel string `json:"intensity_level"` // "easy", "moderate", "hard"
|
||||
WeeklyHours int `json:"weekly_hours"` // Available training hours per week
|
||||
StartDate string `json:"start_date"` // YYYY-MM-DD
|
||||
IncludeRest bool `json:"include_rest"` // Whether to include rest days
|
||||
TargetEventID *uint `json:"target_event_id"` // Optional target event to periodize for
|
||||
}
|
||||
|
||||
// GenerateResponse contains AI-generated workouts
|
||||
type GenerateResponse struct {
|
||||
Workouts []AIWorkout `json:"workouts"`
|
||||
Rationale string `json:"rationale"` // AI's explanation
|
||||
TotalTSS float64 `json:"total_tss"`
|
||||
RecommendationID uint `json:"recommendation_id"`
|
||||
}
|
||||
|
||||
// AIWorkout represents a single AI-generated workout
|
||||
type AIWorkout struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
ScheduledDate string `json:"scheduled_date"`
|
||||
Duration int `json:"duration"` // seconds
|
||||
Segments []workout.WorkoutSegment `json:"segments"`
|
||||
EstimatedTSS float64 `json:"estimated_tss"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
// AIRecommendation database model
|
||||
type AIRecommendation struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
PromptContext JSONB `gorm:"type:jsonb" json:"prompt_context"`
|
||||
AIResponse string `json:"ai_response"`
|
||||
GeneratedWorkouts JSONB `gorm:"type:jsonb" json:"generated_workouts"`
|
||||
Parameters JSONB `gorm:"type:jsonb" json:"parameters"`
|
||||
Status string `gorm:"default:'generated'" json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (AIRecommendation) TableName() string {
|
||||
return "ai_recommendations"
|
||||
}
|
||||
|
||||
// JSONB type for PostgreSQL JSONB columns
|
||||
type JSONB struct {
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// Scan implements sql.Scanner interface
|
||||
func (j *JSONB) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
j.Data = []byte("{}")
|
||||
return nil
|
||||
}
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
j.Data = bytes
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer interface
|
||||
func (j JSONB) Value() (driver.Value, error) {
|
||||
if len(j.Data) == 0 {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
return j.Data, nil
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler
|
||||
func (j JSONB) MarshalJSON() ([]byte, error) {
|
||||
if len(j.Data) == 0 {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
return j.Data, nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler
|
||||
func (j *JSONB) UnmarshalJSON(data []byte) error {
|
||||
j.Data = data
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserContext contains all user data sent to AI
|
||||
type UserContext struct {
|
||||
FTP int `json:"ftp"`
|
||||
MaxHR int `json:"max_hr"`
|
||||
RestingHR int `json:"resting_hr"`
|
||||
Weight float64 `json:"weight"`
|
||||
TrainingGoal string `json:"training_goal"`
|
||||
WeeklyHours int `json:"weekly_hours"`
|
||||
RecentWorkouts []RecentWorkoutSummary `json:"recent_workouts"`
|
||||
TrainingLoad TrainingLoadSummary `json:"training_load"`
|
||||
UpcomingEvents []UpcomingEventSummary `json:"upcoming_events,omitempty"`
|
||||
TargetEvent *UpcomingEventSummary `json:"target_event,omitempty"`
|
||||
Nutrition *NutritionContext `json:"nutrition,omitempty"`
|
||||
}
|
||||
|
||||
// NutritionContext contains nutrition data for AI
|
||||
type NutritionContext struct {
|
||||
Goal string `json:"goal"`
|
||||
DailyCalories int `json:"daily_calories"`
|
||||
ProteinG int `json:"protein_g"`
|
||||
CarbsG int `json:"carbs_g"`
|
||||
FatG int `json:"fat_g"`
|
||||
DietaryPref string `json:"dietary_preference"`
|
||||
}
|
||||
|
||||
// UpcomingEventSummary summarizes an upcoming race/event for AI context
|
||||
type UpcomingEventSummary struct {
|
||||
Name string `json:"name"`
|
||||
Date string `json:"date"`
|
||||
EventType string `json:"event_type"`
|
||||
Distance float64 `json:"distance"`
|
||||
Priority string `json:"priority"`
|
||||
DaysAway int `json:"days_away"`
|
||||
}
|
||||
|
||||
// RecentWorkoutSummary summarizes a completed workout
|
||||
type RecentWorkoutSummary struct {
|
||||
Date string `json:"date"`
|
||||
Type string `json:"type"`
|
||||
Duration int `json:"duration"`
|
||||
AvgPower int `json:"avg_power"`
|
||||
TSS float64 `json:"tss"`
|
||||
}
|
||||
|
||||
// TrainingLoadSummary contains CTL/ATL/TSB metrics
|
||||
type TrainingLoadSummary struct {
|
||||
CTL float64 `json:"ctl"` // Chronic Training Load (42-day EMA)
|
||||
ATL float64 `json:"atl"` // Acute Training Load (7-day EMA)
|
||||
TSB float64 `json:"tsb"` // Training Stress Balance (CTL - ATL)
|
||||
}
|
||||
|
||||
// DeepSeek API structures
|
||||
type DeepSeekRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []DeepSeekMessage `json:"messages"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
}
|
||||
|
||||
type DeepSeekMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type DeepSeekResponse struct {
|
||||
Choices []struct {
|
||||
Message DeepSeekMessage `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
} `json:"error"`
|
||||
}
|
||||
214
internal/ai/prompt.go
Normal file
214
internal/ai/prompt.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BuildSystemPrompt creates the system message for DeepSeek
|
||||
func BuildSystemPrompt() string {
|
||||
return `You are an expert cycling coach with deep knowledge of power-based training, periodization, and workout design.
|
||||
|
||||
Your task is to generate structured cycling workouts based on user data and goals. You must respond ONLY with valid JSON.
|
||||
|
||||
POWER ZONES (% of FTP):
|
||||
- Recovery: 30-55% FTP
|
||||
- Endurance/Zone 2: 56-75% FTP
|
||||
- Tempo: 76-87% FTP
|
||||
- Sweet Spot: 88-94% FTP
|
||||
- Threshold/FTP: 95-105% FTP
|
||||
- VO2max: 106-120% FTP
|
||||
- Anaerobic: 121-150% FTP
|
||||
- Neuromuscular: >150% FTP
|
||||
|
||||
WORKOUT STRUCTURE:
|
||||
- All workouts must have warmup, main intervals, and cooldown
|
||||
- Duration is in seconds
|
||||
- Power is expressed as decimal (0.65 = 65% FTP, 1.0 = 100% FTP)
|
||||
- Segment types: "warmup", "steadystate", "interval", "ramp", "cooldown", "rest", "freeride"
|
||||
- For steady state efforts, use "power" field
|
||||
- For variable efforts, use "power_low" and "power_high" fields
|
||||
|
||||
TSS CALCULATION:
|
||||
TSS = (duration_seconds × NP² / FTP²) / 36
|
||||
Approximate: duration_minutes × intensity_factor²
|
||||
|
||||
TRAINING PRINCIPLES:
|
||||
1. Progressive overload within user's current fitness
|
||||
2. Balance intensity and volume
|
||||
3. Include recovery when needed
|
||||
4. Respect weekly hour constraints
|
||||
5. Build on recent training load (CTL/ATL/TSB)
|
||||
6. Vary workouts to prevent monotony
|
||||
7. Include appropriate warmup and cooldown for all workouts
|
||||
|
||||
EVENT-BASED PERIODIZATION (when a target event is provided):
|
||||
- Structure the plan around the target event date using standard cycling periodization:
|
||||
- BUILD phase: Progressive volume/intensity increase (furthest from event)
|
||||
- PEAK phase: Highest intensity, race-specific intervals
|
||||
- TAPER phase: Reduce volume while maintaining intensity
|
||||
- RACE DAY: Generate a "Race" type workout on the event date with pre-race notes
|
||||
- RECOVERY: Easy rides after race day
|
||||
- A-priority race: 7-10 day taper, reduce volume 30-50%, maintain intensity
|
||||
- B-priority race: 3-5 day lighter taper
|
||||
- C-priority race: No taper, treat as training race
|
||||
- On race day, create a workout with type "Race" and include race distance/event details in notes
|
||||
- If multiple events exist, avoid scheduling hard workouts within 2 days of any event
|
||||
|
||||
NUTRITION GUIDANCE (when nutrition context is provided):
|
||||
- Include brief fueling notes in the "notes" field for each workout:
|
||||
- Pre-ride: what to eat 1-2 hours before (e.g., "Pre: oatmeal + banana, 400kcal")
|
||||
- During: fueling for rides >60min (e.g., "During: 60g carbs/hr, sports drink")
|
||||
- Post-ride: recovery nutrition within 30min (e.g., "Post: protein shake + rice, 500kcal")
|
||||
- For easy/recovery rides: lighter fueling notes
|
||||
- For hard/long rides: emphasize carb loading and during-ride nutrition
|
||||
- Keep fueling notes concise (one line each)
|
||||
|
||||
OUTPUT FORMAT (JSON only - BE CONCISE):
|
||||
{
|
||||
"workouts": [
|
||||
{
|
||||
"title": "Workout Name",
|
||||
"description": "Brief description",
|
||||
"type": "Endurance|Tempo|Threshold|VO2 Max|Recovery|Sprint",
|
||||
"scheduled_date": "YYYY-MM-DD",
|
||||
"duration": 3600,
|
||||
"segments": [
|
||||
{"type": "warmup", "duration": 600, "power_low": 0.40, "power_high": 0.65},
|
||||
{"type": "steadystate", "duration": 2400, "power": 0.65, "cadence": 85},
|
||||
{"type": "cooldown", "duration": 600, "power_low": 0.65, "power_high": 0.40}
|
||||
],
|
||||
"estimated_tss": 45.0,
|
||||
"notes": "Brief coaching note"
|
||||
}
|
||||
],
|
||||
"rationale": "Brief plan overview"
|
||||
}
|
||||
|
||||
IMPORTANT: Keep descriptions and notes VERY SHORT (max 10 words each). Response must be valid, complete JSON.`
|
||||
}
|
||||
|
||||
// BuildUserPrompt creates the user message with context
|
||||
func BuildUserPrompt(ctx UserContext, req GenerateRequest) string {
|
||||
contextJSON, _ := json.MarshalIndent(ctx, "", " ")
|
||||
|
||||
focusAreasStr := strings.Join(req.FocusAreas, ", ")
|
||||
|
||||
// Build event context section
|
||||
eventSection := ""
|
||||
if ctx.TargetEvent != nil {
|
||||
eventSection = fmt.Sprintf(`
|
||||
TARGET EVENT:
|
||||
- Name: %s
|
||||
- Date: %s (%d days away)
|
||||
- Type: %s
|
||||
- Distance: %.0f km
|
||||
- Priority: %s-race
|
||||
- IMPORTANT: Periodize the plan to peak for this event. Apply appropriate taper based on priority.
|
||||
On event day (%s), generate a "Race" type workout with event details in notes.
|
||||
`,
|
||||
ctx.TargetEvent.Name,
|
||||
ctx.TargetEvent.Date,
|
||||
ctx.TargetEvent.DaysAway,
|
||||
ctx.TargetEvent.EventType,
|
||||
ctx.TargetEvent.Distance,
|
||||
ctx.TargetEvent.Priority,
|
||||
ctx.TargetEvent.Date,
|
||||
)
|
||||
}
|
||||
|
||||
if len(ctx.UpcomingEvents) > 0 {
|
||||
eventSection += "\nUPCOMING EVENTS (avoid hard workouts within 2 days of these):\n"
|
||||
for _, ev := range ctx.UpcomingEvents {
|
||||
eventSection += fmt.Sprintf("- %s (%s, %s-race, %d days away)\n",
|
||||
ev.Name, ev.Date, ev.Priority, ev.DaysAway)
|
||||
}
|
||||
}
|
||||
|
||||
// Build nutrition context section
|
||||
nutritionSection := ""
|
||||
if ctx.Nutrition != nil {
|
||||
nutritionSection = fmt.Sprintf(`
|
||||
NUTRITION CONTEXT:
|
||||
- Goal: %s
|
||||
- Daily Calorie Target: %d kcal (before workout additions)
|
||||
- Macro Targets: Protein %dg, Carbs %dg, Fat %dg
|
||||
- Dietary Preference: %s
|
||||
- IMPORTANT: Include brief fueling notes (pre-ride, during, post-ride) in each workout's "notes" field. Adjust fueling intensity to match workout intensity.
|
||||
`,
|
||||
ctx.Nutrition.Goal,
|
||||
ctx.Nutrition.DailyCalories,
|
||||
ctx.Nutrition.ProteinG,
|
||||
ctx.Nutrition.CarbsG,
|
||||
ctx.Nutrition.FatG,
|
||||
ctx.Nutrition.DietaryPref,
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`Generate a %d-day training plan with the following requirements:
|
||||
|
||||
USER CONTEXT:
|
||||
%s
|
||||
|
||||
PLAN PARAMETERS:
|
||||
- Duration: %d days
|
||||
- Focus Areas: %s
|
||||
- Intensity Level: %s
|
||||
- Weekly Available Hours: %d
|
||||
- Start Date: %s
|
||||
- Include Rest Days: %v
|
||||
%s%s
|
||||
REQUIREMENTS:
|
||||
1. Generate workouts that fit within %d weekly hours
|
||||
2. Focus on: %s
|
||||
3. Overall intensity: %s
|
||||
4. Respect user's current fitness (FTP: %d, recent training load)
|
||||
5. Include variety and progressive adaptation
|
||||
6. Provide clear workout descriptions and coaching notes
|
||||
7. Ensure total duration of segments matches workout duration
|
||||
8. Use appropriate power zones for each workout type
|
||||
9. Include proper warmup and cooldown for every workout
|
||||
|
||||
IMPORTANT:
|
||||
- Return ONLY valid JSON in the exact format specified in the system prompt
|
||||
- Do not include any explanatory text before or after the JSON
|
||||
- Ensure all dates are sequential starting from %s
|
||||
- Calculate realistic TSS values for each workout
|
||||
- Segment durations must sum to workout duration (in seconds)
|
||||
- Power values should be between 0.30 and 2.50 (30%% to 250%% FTP)
|
||||
|
||||
Return ONLY valid JSON in the exact format specified in the system prompt.`,
|
||||
req.PlanDuration,
|
||||
string(contextJSON),
|
||||
req.PlanDuration,
|
||||
focusAreasStr,
|
||||
req.IntensityLevel,
|
||||
req.WeeklyHours,
|
||||
req.StartDate,
|
||||
req.IncludeRest,
|
||||
eventSection,
|
||||
nutritionSection,
|
||||
req.WeeklyHours,
|
||||
focusAreasStr,
|
||||
req.IntensityLevel,
|
||||
ctx.FTP,
|
||||
req.StartDate,
|
||||
)
|
||||
}
|
||||
|
||||
// extractJSON attempts to extract JSON from response that might be wrapped in markdown
|
||||
func extractJSON(response string) string {
|
||||
// Remove markdown code blocks if present
|
||||
if idx := strings.Index(response, "```json"); idx != -1 {
|
||||
response = response[idx+7:]
|
||||
} else if idx := strings.Index(response, "```"); idx != -1 {
|
||||
response = response[idx+3:]
|
||||
}
|
||||
|
||||
if idx := strings.Index(response, "```"); idx != -1 {
|
||||
response = response[:idx]
|
||||
}
|
||||
|
||||
return strings.TrimSpace(response)
|
||||
}
|
||||
66
internal/ai/repository.go
Normal file
66
internal/ai/repository.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"rideaware/pkg/database"
|
||||
)
|
||||
|
||||
type Repository struct{}
|
||||
|
||||
func NewRepository() *Repository {
|
||||
return &Repository{}
|
||||
}
|
||||
|
||||
// SaveRecommendation stores the AI recommendation
|
||||
func (r *Repository) SaveRecommendation(
|
||||
userID uint,
|
||||
userCtx UserContext,
|
||||
params GenerateRequest,
|
||||
aiResponse string,
|
||||
generated *GenerateResponse,
|
||||
) (uint, error) {
|
||||
|
||||
contextJSON, _ := json.Marshal(userCtx)
|
||||
paramsJSON, _ := json.Marshal(params)
|
||||
workoutsJSON, _ := json.Marshal(generated.Workouts)
|
||||
|
||||
rec := &AIRecommendation{
|
||||
UserID: userID,
|
||||
PromptContext: JSONB{Data: contextJSON},
|
||||
AIResponse: aiResponse,
|
||||
GeneratedWorkouts: JSONB{Data: workoutsJSON},
|
||||
Parameters: JSONB{Data: paramsJSON},
|
||||
Status: "generated",
|
||||
}
|
||||
|
||||
if err := database.DB.Create(rec).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return rec.ID, nil
|
||||
}
|
||||
|
||||
// GetRecommendation retrieves a recommendation by ID
|
||||
func (r *Repository) GetRecommendation(id uint, userID uint) (*AIRecommendation, error) {
|
||||
var rec AIRecommendation
|
||||
err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&rec).Error
|
||||
return &rec, err
|
||||
}
|
||||
|
||||
// GetUserRecommendations fetches recent recommendations for a user
|
||||
func (r *Repository) GetUserRecommendations(userID uint, limit int) ([]AIRecommendation, error) {
|
||||
var recs []AIRecommendation
|
||||
err := database.DB.Where("user_id = ?", userID).
|
||||
Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Find(&recs).Error
|
||||
return recs, err
|
||||
}
|
||||
|
||||
// UpdateRecommendationStatus marks a recommendation as scheduled/rejected
|
||||
func (r *Repository) UpdateRecommendationStatus(id uint, status string) error {
|
||||
return database.DB.Model(&AIRecommendation{}).
|
||||
Where("id = ?", id).
|
||||
Update("status", status).Error
|
||||
}
|
||||
478
internal/ai/service.go
Normal file
478
internal/ai/service.go
Normal file
@@ -0,0 +1,478 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"rideaware/internal/event"
|
||||
"rideaware/internal/nutrition"
|
||||
"rideaware/internal/stats"
|
||||
"rideaware/internal/user"
|
||||
"rideaware/internal/workout"
|
||||
"rideaware/pkg/database"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
userRepo *user.Repository
|
||||
statsRepo *stats.Repository
|
||||
workoutRepo *workout.Repository
|
||||
eventRepo *event.Repository
|
||||
nutritionSvc *nutrition.Service
|
||||
aiRepo *Repository
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{
|
||||
userRepo: user.NewRepository(),
|
||||
statsRepo: stats.NewRepository(),
|
||||
workoutRepo: workout.NewRepository(),
|
||||
eventRepo: event.NewRepository(),
|
||||
nutritionSvc: nutrition.NewService(),
|
||||
aiRepo: NewRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateWorkouts orchestrates the AI generation process
|
||||
func (s *Service) GenerateWorkouts(userID uint, req GenerateRequest) (*GenerateResponse, error) {
|
||||
// 1. Gather user context
|
||||
userCtx, err := s.buildUserContext(userID, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build user context: %w", err)
|
||||
}
|
||||
|
||||
// 2. Call DeepSeek API with retry logic
|
||||
var aiResponse string
|
||||
maxRetries := 2
|
||||
for i := 0; i <= maxRetries; i++ {
|
||||
if i > 0 {
|
||||
log.Printf("[AI] Retry attempt %d/%d", i, maxRetries)
|
||||
time.Sleep(time.Duration(i) * 2 * time.Second) // Exponential backoff
|
||||
}
|
||||
aiResponse, err = s.callDeepSeekAPI(userCtx, req)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
log.Printf("[AI] API call attempt %d failed: %v", i+1, err)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AI API call failed after %d attempts: %w", maxRetries+1, err)
|
||||
}
|
||||
|
||||
// 3. Parse and validate response
|
||||
genResponse, err := s.parseAIResponse(aiResponse)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse AI response: %w", err)
|
||||
}
|
||||
|
||||
// 4. Fix duration mismatches (AI sometimes miscalculates)
|
||||
s.fixWorkoutDurations(genResponse.Workouts)
|
||||
|
||||
// 5. Filter out incomplete workouts (defensive against malformed AI responses)
|
||||
validWorkouts := make([]AIWorkout, 0, len(genResponse.Workouts))
|
||||
for i, w := range genResponse.Workouts {
|
||||
if w.Duration == 0 || len(w.Segments) == 0 || w.Title == "" {
|
||||
log.Printf("[AI] Skipping incomplete workout %d (duration=%d, segments=%d, title=%q)",
|
||||
i, w.Duration, len(w.Segments), w.Title)
|
||||
continue
|
||||
}
|
||||
validWorkouts = append(validWorkouts, w)
|
||||
}
|
||||
genResponse.Workouts = validWorkouts
|
||||
|
||||
if len(validWorkouts) == 0 {
|
||||
return nil, fmt.Errorf("no valid workouts generated by AI")
|
||||
}
|
||||
|
||||
// 6. Validate workout structures
|
||||
if err := validateWorkouts(genResponse.Workouts); err != nil {
|
||||
return nil, fmt.Errorf("workout validation failed: %w", err)
|
||||
}
|
||||
|
||||
// 7. Calculate total TSS
|
||||
totalTSS := 0.0
|
||||
for _, w := range genResponse.Workouts {
|
||||
totalTSS += w.EstimatedTSS
|
||||
}
|
||||
genResponse.TotalTSS = totalTSS
|
||||
|
||||
// 8. Store recommendation in database
|
||||
recID, err := s.aiRepo.SaveRecommendation(userID, userCtx, req, aiResponse, genResponse)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to save recommendation: %w", err)
|
||||
}
|
||||
|
||||
genResponse.RecommendationID = recID
|
||||
return genResponse, nil
|
||||
}
|
||||
|
||||
// buildUserContext gathers all relevant user data
|
||||
func (s *Service) buildUserContext(userID uint, req GenerateRequest) (UserContext, error) {
|
||||
var ctx UserContext
|
||||
|
||||
// Get user profile
|
||||
userObj, err := s.userRepo.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return ctx, err
|
||||
}
|
||||
|
||||
// Set default values if profile doesn't exist
|
||||
ctx.FTP = 200 // default FTP
|
||||
ctx.MaxHR = 180
|
||||
ctx.RestingHR = 60
|
||||
ctx.Weight = 70.0
|
||||
ctx.WeeklyHours = req.WeeklyHours
|
||||
|
||||
// Override with actual profile data if available
|
||||
if userObj.Profile != nil {
|
||||
if userObj.Profile.FTP > 0 {
|
||||
ctx.FTP = userObj.Profile.FTP
|
||||
}
|
||||
if userObj.Profile.MaxHR > 0 {
|
||||
ctx.MaxHR = userObj.Profile.MaxHR
|
||||
}
|
||||
if userObj.Profile.RestingHR > 0 {
|
||||
ctx.RestingHR = userObj.Profile.RestingHR
|
||||
}
|
||||
if userObj.Profile.Weight > 0 {
|
||||
ctx.Weight = userObj.Profile.Weight
|
||||
}
|
||||
}
|
||||
|
||||
// Get recent workouts (last 30 days)
|
||||
endDate := time.Now()
|
||||
startDate := endDate.AddDate(0, 0, -30)
|
||||
workouts, err := s.workoutRepo.GetWorkoutsByDateRange(userID, startDate, endDate)
|
||||
if err == nil {
|
||||
ctx.RecentWorkouts = s.summarizeWorkouts(workouts, ctx.FTP)
|
||||
} else {
|
||||
ctx.RecentWorkouts = []RecentWorkoutSummary{}
|
||||
}
|
||||
|
||||
// Get training load (CTL/ATL/TSB)
|
||||
ctx.TrainingLoad = s.calculateTrainingLoad(userID, ctx.FTP)
|
||||
|
||||
// Get upcoming events
|
||||
upcomingEvents, err := s.eventRepo.GetUpcomingEvents(userID, 5)
|
||||
if err == nil && len(upcomingEvents) > 0 {
|
||||
now := time.Now()
|
||||
now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
for _, ev := range upcomingEvents {
|
||||
evDate := time.Date(ev.EventDate.Year(), ev.EventDate.Month(), ev.EventDate.Day(), 0, 0, 0, 0, ev.EventDate.Location())
|
||||
daysAway := int(evDate.Sub(now).Hours() / 24)
|
||||
summary := UpcomingEventSummary{
|
||||
Name: ev.Name,
|
||||
Date: ev.EventDate.Format("2006-01-02"),
|
||||
EventType: ev.EventType,
|
||||
Distance: ev.Distance,
|
||||
Priority: ev.Priority,
|
||||
DaysAway: daysAway,
|
||||
}
|
||||
ctx.UpcomingEvents = append(ctx.UpcomingEvents, summary)
|
||||
|
||||
// If this is the target event, set it
|
||||
if req.TargetEventID != nil && ev.ID == *req.TargetEventID {
|
||||
target := summary
|
||||
ctx.TargetEvent = &target
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get nutrition context
|
||||
nutritionTargets, err := s.nutritionSvc.GetTargets(userID)
|
||||
if err == nil && nutritionTargets != nil && nutritionTargets.IsConfigured {
|
||||
ctx.Nutrition = &NutritionContext{
|
||||
Goal: nutritionTargets.NutritionGoal,
|
||||
DailyCalories: nutritionTargets.DailyCalories,
|
||||
ProteinG: nutritionTargets.Protein,
|
||||
CarbsG: nutritionTargets.Carbs,
|
||||
FatG: nutritionTargets.Fat,
|
||||
DietaryPref: nutritionTargets.DietaryPref,
|
||||
}
|
||||
}
|
||||
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
// summarizeWorkouts converts workouts to summaries
|
||||
func (s *Service) summarizeWorkouts(workouts []workout.Workout, ftp int) []RecentWorkoutSummary {
|
||||
summaries := make([]RecentWorkoutSummary, 0, len(workouts))
|
||||
|
||||
// Limit to last 10 workouts to keep prompt size manageable
|
||||
count := 0
|
||||
for _, w := range workouts {
|
||||
if w.Status != "completed" {
|
||||
continue
|
||||
}
|
||||
if count >= 10 {
|
||||
break
|
||||
}
|
||||
count++
|
||||
|
||||
tss := 0.0
|
||||
if ftp > 0 && w.AvgPower > 0 && w.Duration > 0 {
|
||||
// TSS = (duration_seconds × (avgPower/FTP)²) / 36
|
||||
intensityFactor := float64(w.AvgPower) / float64(ftp)
|
||||
tss = (float64(w.Duration) * intensityFactor * intensityFactor) / 36.0
|
||||
}
|
||||
|
||||
summaries = append(summaries, RecentWorkoutSummary{
|
||||
Date: w.ScheduledDate.Format("2006-01-02"),
|
||||
Type: w.Type,
|
||||
Duration: w.Duration,
|
||||
AvgPower: w.AvgPower,
|
||||
TSS: tss,
|
||||
})
|
||||
}
|
||||
|
||||
return summaries
|
||||
}
|
||||
|
||||
// calculateTrainingLoad computes CTL/ATL/TSB
|
||||
func (s *Service) calculateTrainingLoad(userID uint, ftp int) TrainingLoadSummary {
|
||||
summary := TrainingLoadSummary{CTL: 0, ATL: 0, TSB: 0}
|
||||
|
||||
if ftp == 0 {
|
||||
return summary
|
||||
}
|
||||
|
||||
// Get daily TSS for last 42 days (for CTL)
|
||||
dailyTSS, err := s.statsRepo.GetDailyTSS(userID, ftp, 42)
|
||||
if err != nil || len(dailyTSS) == 0 {
|
||||
return summary
|
||||
}
|
||||
|
||||
// Calculate CTL (42-day exponential moving average)
|
||||
ctlTC := 42.0 // time constant
|
||||
ctl := 0.0
|
||||
for _, day := range dailyTSS {
|
||||
ctl = ctl + (day.TSS-ctl)*(1.0/ctlTC)
|
||||
}
|
||||
|
||||
// Calculate ATL (7-day exponential moving average, using last 7 days)
|
||||
atlTC := 7.0
|
||||
atl := 0.0
|
||||
start := len(dailyTSS) - 7
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
for i := start; i < len(dailyTSS); i++ {
|
||||
atl = atl + (dailyTSS[i].TSS-atl)*(1.0/atlTC)
|
||||
}
|
||||
|
||||
summary.CTL = math.Round(ctl*10) / 10
|
||||
summary.ATL = math.Round(atl*10) / 10
|
||||
summary.TSB = math.Round((ctl-atl)*10) / 10
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
// callDeepSeekAPI makes the HTTP request to DeepSeek
|
||||
func (s *Service) callDeepSeekAPI(userCtx UserContext, req GenerateRequest) (string, error) {
|
||||
apiKey := os.Getenv("DEEPSEEK_API_KEY")
|
||||
if apiKey == "" {
|
||||
return "", fmt.Errorf("DEEPSEEK_API_KEY not configured")
|
||||
}
|
||||
|
||||
// Build prompts
|
||||
systemPrompt := BuildSystemPrompt()
|
||||
userPrompt := BuildUserPrompt(userCtx, req)
|
||||
|
||||
// Create request
|
||||
deepseekReq := DeepSeekRequest{
|
||||
Model: "deepseek-chat",
|
||||
Messages: []DeepSeekMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
},
|
||||
Temperature: 0.7,
|
||||
MaxTokens: 8000, // Ensure enough tokens for complete response
|
||||
}
|
||||
|
||||
reqBody, _ := json.Marshal(deepseekReq)
|
||||
|
||||
log.Printf("[AI] Calling DeepSeek API for user context with FTP=%d", userCtx.FTP)
|
||||
|
||||
// Make HTTP request
|
||||
httpReq, err := http.NewRequest("POST", "https://api.deepseek.com/v1/chat/completions", bytes.NewBuffer(reqBody))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
// Increase timeout to 180 seconds (3 minutes) for AI generation
|
||||
client := &http.Client{Timeout: 180 * time.Second}
|
||||
|
||||
log.Printf("[AI] Sending request to DeepSeek API...")
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("DeepSeek API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var deepseekResp DeepSeekResponse
|
||||
if err := json.Unmarshal(body, &deepseekResp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if deepseekResp.Error != nil {
|
||||
return "", fmt.Errorf("DeepSeek API error: %s", deepseekResp.Error.Message)
|
||||
}
|
||||
|
||||
if len(deepseekResp.Choices) == 0 {
|
||||
return "", fmt.Errorf("no response from DeepSeek API")
|
||||
}
|
||||
|
||||
content := deepseekResp.Choices[0].Message.Content
|
||||
log.Printf("[AI] DeepSeek API call successful, response length: %d bytes", len(content))
|
||||
|
||||
// Log preview of response
|
||||
if len(content) > 200 {
|
||||
log.Printf("[AI] Response preview: %s...", content[:200])
|
||||
} else {
|
||||
log.Printf("[AI] Full response: %s", content)
|
||||
}
|
||||
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// parseAIResponse extracts structured workout data from AI response
|
||||
func (s *Service) parseAIResponse(aiResponse string) (*GenerateResponse, error) {
|
||||
var response GenerateResponse
|
||||
|
||||
// Try to extract JSON from response (AI might wrap it in markdown)
|
||||
cleaned := extractJSON(aiResponse)
|
||||
|
||||
log.Printf("[AI] Raw response length: %d bytes", len(aiResponse))
|
||||
log.Printf("[AI] Cleaned response length: %d bytes", len(cleaned))
|
||||
|
||||
// Log first 500 chars for debugging
|
||||
if len(cleaned) > 500 {
|
||||
log.Printf("[AI] Response preview: %s...", cleaned[:500])
|
||||
} else {
|
||||
log.Printf("[AI] Full cleaned response: %s", cleaned)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(cleaned), &response); err != nil {
|
||||
log.Printf("[AI] Failed to parse JSON: %v", err)
|
||||
log.Printf("[AI] Full raw response: %s", aiResponse)
|
||||
return nil, fmt.Errorf("failed to parse JSON: %w (check logs for full response)", err)
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// ScheduleWorkouts converts AI recommendations to actual workouts
|
||||
func (s *Service) ScheduleWorkouts(userID uint, recommendationID uint, workoutIndices []int) ([]*workout.Workout, error) {
|
||||
// Get the recommendation
|
||||
rec, err := s.aiRepo.GetRecommendation(recommendationID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Prevent duplicate scheduling
|
||||
if rec.Status == "scheduled" {
|
||||
return nil, fmt.Errorf("this training plan has already been scheduled")
|
||||
}
|
||||
|
||||
// Parse generated workouts
|
||||
var aiWorkouts []AIWorkout
|
||||
if err := json.Unmarshal(rec.GeneratedWorkouts.Data, &aiWorkouts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Schedule selected workouts
|
||||
var scheduled []*workout.Workout
|
||||
for _, idx := range workoutIndices {
|
||||
if idx < 0 || idx >= len(aiWorkouts) {
|
||||
continue
|
||||
}
|
||||
|
||||
aiWorkout := aiWorkouts[idx]
|
||||
scheduledDate, _ := time.Parse("2006-01-02", aiWorkout.ScheduledDate)
|
||||
|
||||
// Convert to workout model
|
||||
workoutData := workout.WorkoutDataJSON{
|
||||
Name: aiWorkout.Title,
|
||||
TotalDuration: aiWorkout.Duration,
|
||||
Segments: aiWorkout.Segments,
|
||||
}
|
||||
|
||||
newWorkout := &workout.Workout{
|
||||
UserID: userID,
|
||||
Title: aiWorkout.Title,
|
||||
Description: aiWorkout.Description,
|
||||
Type: aiWorkout.Type,
|
||||
Status: "planned",
|
||||
ScheduledDate: scheduledDate,
|
||||
Duration: aiWorkout.Duration,
|
||||
WorkoutData: workoutData,
|
||||
Notes: aiWorkout.Notes,
|
||||
}
|
||||
|
||||
if err := s.workoutRepo.CreateWorkout(newWorkout); err != nil {
|
||||
log.Printf("[AI] Failed to create workout: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
scheduled = append(scheduled, newWorkout)
|
||||
}
|
||||
|
||||
// Update recommendation status
|
||||
if len(scheduled) > 0 {
|
||||
s.aiRepo.UpdateRecommendationStatus(recommendationID, "scheduled")
|
||||
}
|
||||
|
||||
return scheduled, nil
|
||||
}
|
||||
|
||||
// GetUserRecommendations fetches recommendation history
|
||||
func (s *Service) GetUserRecommendations(userID uint, limit int) ([]AIRecommendation, error) {
|
||||
return s.aiRepo.GetUserRecommendations(userID, limit)
|
||||
}
|
||||
|
||||
// fixWorkoutDurations corrects duration mismatches by recalculating from segments
|
||||
func (s *Service) fixWorkoutDurations(workouts []AIWorkout) {
|
||||
for i := range workouts {
|
||||
totalDuration := 0
|
||||
for _, seg := range workouts[i].Segments {
|
||||
totalDuration += seg.Duration
|
||||
}
|
||||
|
||||
// If there's a mismatch, fix the workout duration
|
||||
if workouts[i].Duration != totalDuration {
|
||||
log.Printf("[AI] Fixing workout %d duration: %d -> %d (sum of segments)",
|
||||
i, workouts[i].Duration, totalDuration)
|
||||
workouts[i].Duration = totalDuration
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserFTP queries FTP from user_profiles table directly
|
||||
func (s *Service) GetUserFTP(userID uint) (int, error) {
|
||||
var ftp int
|
||||
err := database.DB.Table("user_profiles").
|
||||
Select("ftp").
|
||||
Where("user_id = ?", userID).
|
||||
Scan(&ftp).Error
|
||||
return ftp, err
|
||||
}
|
||||
156
internal/ai/validator.go
Normal file
156
internal/ai/validator.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// validateWorkouts ensures AI-generated workouts meet quality standards
|
||||
func validateWorkouts(workouts []AIWorkout) error {
|
||||
if len(workouts) == 0 {
|
||||
return fmt.Errorf("no workouts generated")
|
||||
}
|
||||
|
||||
for i, w := range workouts {
|
||||
if err := validateWorkout(w, i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWorkout(w AIWorkout, index int) error {
|
||||
// Required fields
|
||||
if w.Title == "" {
|
||||
return fmt.Errorf("workout %d: title is required", index)
|
||||
}
|
||||
|
||||
if w.Duration <= 0 {
|
||||
return fmt.Errorf("workout %d: invalid duration %d", index, w.Duration)
|
||||
}
|
||||
|
||||
if w.Duration > 86400 {
|
||||
return fmt.Errorf("workout %d: duration exceeds 24 hours", index)
|
||||
}
|
||||
|
||||
// Validate date format
|
||||
if _, err := time.Parse("2006-01-02", w.ScheduledDate); err != nil {
|
||||
return fmt.Errorf("workout %d: invalid date format %s", index, w.ScheduledDate)
|
||||
}
|
||||
|
||||
// Validate segments
|
||||
if len(w.Segments) == 0 {
|
||||
return fmt.Errorf("workout %d: must have at least one segment", index)
|
||||
}
|
||||
|
||||
if len(w.Segments) > 100 {
|
||||
return fmt.Errorf("workout %d: too many segments (%d), maximum 100", index, len(w.Segments))
|
||||
}
|
||||
|
||||
totalDuration := 0
|
||||
for j, seg := range w.Segments {
|
||||
if seg.Duration <= 0 {
|
||||
return fmt.Errorf("workout %d, segment %d: invalid duration %d", index, j, seg.Duration)
|
||||
}
|
||||
|
||||
// Validate power values are reasonable (0-300% FTP)
|
||||
if seg.Power < 0 || seg.Power > 3.0 {
|
||||
return fmt.Errorf("workout %d, segment %d: power %.2f out of range (0-3.0)", index, j, seg.Power)
|
||||
}
|
||||
|
||||
if seg.PowerLow < 0 || seg.PowerLow > 3.0 {
|
||||
return fmt.Errorf("workout %d, segment %d: power_low %.2f out of range (0-3.0)", index, j, seg.PowerLow)
|
||||
}
|
||||
|
||||
if seg.PowerHigh < 0 || seg.PowerHigh > 3.0 {
|
||||
return fmt.Errorf("workout %d, segment %d: power_high %.2f out of range (0-3.0)", index, j, seg.PowerHigh)
|
||||
}
|
||||
|
||||
// Validate that power_low <= power_high (except for ramps/cooldowns where power descends)
|
||||
// For warmup, cooldown, and ramp types, power can go from high to low
|
||||
if seg.PowerLow > 0 && seg.PowerHigh > 0 && seg.PowerLow > seg.PowerHigh {
|
||||
// Allow descending power for warmup, cooldown, and ramp segments
|
||||
if seg.Type != "warmup" && seg.Type != "cooldown" && seg.Type != "ramp" {
|
||||
return fmt.Errorf("workout %d, segment %d: power_low (%.2f) > power_high (%.2f)", index, j, seg.PowerLow, seg.PowerHigh)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate segment type
|
||||
validTypes := map[string]bool{
|
||||
"warmup": true,
|
||||
"cooldown": true,
|
||||
"steadystate": true,
|
||||
"interval": true,
|
||||
"ramp": true,
|
||||
"rest": true,
|
||||
"freeride": true,
|
||||
}
|
||||
if !validTypes[seg.Type] {
|
||||
return fmt.Errorf("workout %d, segment %d: invalid type %s", index, j, seg.Type)
|
||||
}
|
||||
|
||||
totalDuration += seg.Duration
|
||||
}
|
||||
|
||||
// Verify total duration matches sum of segments (within 5% tolerance)
|
||||
tolerance := float64(w.Duration) * 0.05
|
||||
diff := math.Abs(float64(totalDuration - w.Duration))
|
||||
if diff > tolerance {
|
||||
return fmt.Errorf("workout %d: duration mismatch (declared %d, segments total %d)",
|
||||
index, w.Duration, totalDuration)
|
||||
}
|
||||
|
||||
// Validate TSS is reasonable (0-500)
|
||||
if w.EstimatedTSS < 0 || w.EstimatedTSS > 500 {
|
||||
return fmt.Errorf("workout %d: unrealistic TSS %.1f (expected 0-500)", index, w.EstimatedTSS)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateGenerateRequest validates user input
|
||||
func validateGenerateRequest(req GenerateRequest) error {
|
||||
if req.PlanDuration < 1 || req.PlanDuration > 28 {
|
||||
return fmt.Errorf("plan_duration must be between 1 and 28 days")
|
||||
}
|
||||
|
||||
if req.WeeklyHours < 1 || req.WeeklyHours > 40 {
|
||||
return fmt.Errorf("weekly_hours must be between 1 and 40")
|
||||
}
|
||||
|
||||
validIntensities := map[string]bool{"easy": true, "moderate": true, "hard": true}
|
||||
if !validIntensities[req.IntensityLevel] {
|
||||
return fmt.Errorf("intensity_level must be 'easy', 'moderate', or 'hard'")
|
||||
}
|
||||
|
||||
if len(req.FocusAreas) == 0 {
|
||||
return fmt.Errorf("at least one focus area is required")
|
||||
}
|
||||
|
||||
if len(req.FocusAreas) > 3 {
|
||||
return fmt.Errorf("maximum 3 focus areas allowed")
|
||||
}
|
||||
|
||||
validFocusAreas := map[string]bool{
|
||||
"endurance": true,
|
||||
"threshold": true,
|
||||
"vo2max": true,
|
||||
"recovery": true,
|
||||
"sprint": true,
|
||||
"sweet_spot": true,
|
||||
}
|
||||
for _, focus := range req.FocusAreas {
|
||||
if !validFocusAreas[focus] {
|
||||
return fmt.Errorf("invalid focus area: %s", focus)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate start date format
|
||||
if _, err := time.Parse("2006-01-02", req.StartDate); err != nil {
|
||||
return fmt.Errorf("invalid start_date format, use YYYY-MM-DD")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -2,8 +2,12 @@ package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"rideaware/internal/config"
|
||||
"rideaware/internal/user"
|
||||
@@ -25,6 +29,7 @@ type SignupRequest struct {
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
@@ -39,6 +44,7 @@ type TokenResponse struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func (h *Handler) Signup(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -55,6 +61,25 @@ func (h *Handler) Signup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
log.Printf("📝 Signup attempt for user: %s (email: %s)", req.Username, req.Email)
|
||||
|
||||
// Verify Turnstile CAPTCHA
|
||||
turnstileSecret := os.Getenv("TURNSTILE_SECRET_KEY")
|
||||
if turnstileSecret != "" {
|
||||
if req.TurnstileToken == "" {
|
||||
log.Printf("❌ Signup rejected: missing Turnstile token for %s", req.Username)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Please complete the verification check"})
|
||||
return
|
||||
}
|
||||
if !verifyTurnstile(turnstileSecret, req.TurnstileToken, r.RemoteAddr) {
|
||||
log.Printf("❌ Signup rejected: Turnstile verification failed for %s", req.Username)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "CAPTCHA verification failed. Please try again."})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
newUser, err := h.userService.CreateUser(req.Username, req.Password, req.Email, req.FirstName, req.LastName)
|
||||
if err != nil {
|
||||
log.Printf("❌ Signup error: %v", err)
|
||||
@@ -66,18 +91,19 @@ func (h *Handler) Signup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
log.Printf("✅ User created: %s (ID: %d)", newUser.Username, newUser.ID)
|
||||
|
||||
accessToken, _ := config.GenerateAccessToken(newUser.ID, newUser.Email, newUser.Username)
|
||||
refreshToken, _ := config.GenerateRefreshToken(newUser.ID, newUser.Email, newUser.Username)
|
||||
accessToken, _ := config.GenerateAccessToken(newUser.ID, newUser.Email, newUser.Username, newUser.Role)
|
||||
refreshToken, _ := config.GenerateRefreshToken(newUser.ID, newUser.Email, newUser.Username, newUser.Role)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: 900,
|
||||
ExpiresIn: 86400, // 24 hours in seconds
|
||||
UserID: newUser.ID,
|
||||
Username: newUser.Username,
|
||||
Email: newUser.Email,
|
||||
Role: newUser.Role,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -106,17 +132,18 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
log.Printf("✅ Login successful for user: %s (ID: %d)", user.Username, user.ID)
|
||||
|
||||
accessToken, _ := config.GenerateAccessToken(user.ID, user.Email, user.Username)
|
||||
refreshToken, _ := config.GenerateRefreshToken(user.ID, user.Email, user.Username)
|
||||
accessToken, _ := config.GenerateAccessToken(user.ID, user.Email, user.Username, user.Role)
|
||||
refreshToken, _ := config.GenerateRefreshToken(user.ID, user.Email, user.Username, user.Role)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: 900,
|
||||
ExpiresIn: 86400, // 24 hours in seconds
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -149,12 +176,12 @@ func (h *Handler) RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("✅ Refresh token valid for user ID: %d", claims.UserID)
|
||||
|
||||
// Generate new access token
|
||||
newAccessToken, _ := config.GenerateAccessToken(claims.UserID, claims.Email, claims.Username)
|
||||
newAccessToken, _ := config.GenerateAccessToken(claims.UserID, claims.Email, claims.Username, claims.Role)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"access_token": newAccessToken,
|
||||
"expires_in": 900,
|
||||
"expires_in": 86400, // 24 hours in seconds
|
||||
})
|
||||
}
|
||||
|
||||
@@ -229,3 +256,38 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Logout successful"})
|
||||
}
|
||||
|
||||
func verifyTurnstile(secret, token, remoteIP string) bool {
|
||||
// Strip port from RemoteAddr if present
|
||||
if idx := strings.LastIndex(remoteIP, ":"); idx != -1 {
|
||||
remoteIP = remoteIP[:idx]
|
||||
}
|
||||
|
||||
resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
url.Values{
|
||||
"secret": {secret},
|
||||
"response": {token},
|
||||
"remoteip": {remoteIP},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("❌ Turnstile verification request failed: %v", err)
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("❌ Turnstile verification read failed: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
log.Printf("❌ Turnstile verification parse failed: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return result.Success
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ var JWT *JWTConfig
|
||||
func InitJWT() {
|
||||
JWT = &JWTConfig{
|
||||
SecretKey: os.Getenv("JWT_SECRET_KEY"),
|
||||
AccessTokenDuration: 15 * time.Minute,
|
||||
RefreshTokenDuration: 7 * 24 * time.Hour,
|
||||
AccessTokenDuration: 24 * time.Hour, // 24 hours (was 15 minutes)
|
||||
RefreshTokenDuration: 30 * 24 * time.Hour, // 30 days (was 7 days)
|
||||
ResetTokenDuration: 1 * time.Hour,
|
||||
}
|
||||
|
||||
@@ -34,15 +34,17 @@ type CustomClaims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
TokenType string `json:"token_type"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func GenerateAccessToken(userID uint, email, username string) (string, error) {
|
||||
func GenerateAccessToken(userID uint, email, username, role string) (string, error) {
|
||||
claims := CustomClaims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Username: username,
|
||||
Role: role,
|
||||
TokenType: "access",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(JWT.AccessTokenDuration)),
|
||||
@@ -55,11 +57,12 @@ func GenerateAccessToken(userID uint, email, username string) (string, error) {
|
||||
return token.SignedString([]byte(JWT.SecretKey))
|
||||
}
|
||||
|
||||
func GenerateRefreshToken(userID uint, email, username string) (string, error) {
|
||||
func GenerateRefreshToken(userID uint, email, username, role string) (string, error) {
|
||||
claims := CustomClaims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Username: username,
|
||||
Role: role,
|
||||
TokenType: "refresh",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(JWT.RefreshTokenDuration)),
|
||||
|
||||
@@ -152,9 +152,9 @@ func (s *Service) SendPasswordResetEmail(email, username, resetLink string) erro
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #1e4e9c 0%, #337cf2 100%); color: white; padding: 20px; border-radius: 8px; }
|
||||
.header { background: linear-gradient(135deg, #1e4e9c 0%%25, #337cf2 100%%25); color: white; padding: 20px; border-radius: 8px; }
|
||||
.content { padding: 20px; background: #f9f9f9; margin: 20px 0; border-radius: 8px; }
|
||||
.button { background: linear-gradient(135deg, #1e4e9c 0%, #337cf2 100%); color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
|
||||
.button { background: linear-gradient(135deg, #1e4e9c 0%%25, #337cf2 100%%25); color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
|
||||
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
@@ -192,9 +192,9 @@ func (s *Service) SendWelcomeEmail(email, username string) error {
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #1e4e9c 0%, #337cf2 100%); color: white; padding: 20px; border-radius: 8px; }
|
||||
.header { background: linear-gradient(135deg, #1e4e9c 0%%25, #337cf2 100%%25); color: white; padding: 20px; border-radius: 8px; }
|
||||
.content { padding: 20px; background: #f9f9f9; margin: 20px 0; border-radius: 8px; }
|
||||
.button { background: linear-gradient(135deg, #1e4e9c 0%, #337cf2 100%); color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
|
||||
.button { background: linear-gradient(135deg, #1e4e9c 0%%25, #337cf2 100%%25); color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
|
||||
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
194
internal/event/handler.go
Normal file
194
internal/event/handler.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"rideaware/internal/config"
|
||||
"rideaware/internal/middleware"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler() *Handler {
|
||||
return &Handler{service: NewService()}
|
||||
}
|
||||
|
||||
func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
EventDate string `json:"event_date"`
|
||||
EventType string `json:"event_type"`
|
||||
Distance float64 `json:"distance"`
|
||||
Priority string `json:"priority"`
|
||||
Location string `json:"location"`
|
||||
Notes string `json:"notes"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
eventDate, err := time.Parse("2006-01-02", req.EventDate)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "invalid event_date format (use YYYY-MM-DD)"})
|
||||
return
|
||||
}
|
||||
|
||||
event, err := h.service.CreateEvent(
|
||||
claims.UserID, req.Name, eventDate, req.EventType,
|
||||
req.Priority, req.Location, req.Notes, req.URL, req.Distance,
|
||||
)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(event)
|
||||
}
|
||||
|
||||
func (h *Handler) GetEvents(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
|
||||
events, err := h.service.GetUserEvents(claims.UserID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "failed to fetch events"})
|
||||
return
|
||||
}
|
||||
|
||||
if events == nil {
|
||||
events = []Event{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(events)
|
||||
}
|
||||
|
||||
func (h *Handler) GetUpcomingEvents(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
|
||||
events, err := h.service.GetUpcomingEvents(claims.UserID, 10)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "failed to fetch events"})
|
||||
return
|
||||
}
|
||||
|
||||
if events == nil {
|
||||
events = []Event{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(events)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
|
||||
idStr := r.URL.Query().Get("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "invalid event id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
EventDate string `json:"event_date"`
|
||||
EventType string `json:"event_type"`
|
||||
Distance float64 `json:"distance"`
|
||||
Priority string `json:"priority"`
|
||||
Location string `json:"location"`
|
||||
Notes string `json:"notes"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
var eventDate time.Time
|
||||
if req.EventDate != "" {
|
||||
eventDate, err = time.Parse("2006-01-02", req.EventDate)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "invalid event_date format"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
event, err := h.service.UpdateEvent(
|
||||
uint(id), claims.UserID, req.Name, eventDate, req.EventType,
|
||||
req.Priority, req.Location, req.Notes, req.URL, req.Distance,
|
||||
)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(event)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
|
||||
idStr := r.URL.Query().Get("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "invalid event id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.DeleteEvent(uint(id), claims.UserID); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "failed to delete event"})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "event deleted"})
|
||||
}
|
||||
|
||||
func (h *Handler) GetEventTypes(w http.ResponseWriter, r *http.Request) {
|
||||
types := []map[string]interface{}{
|
||||
{"id": "race", "name": "Race", "icon": "\U0001F3C1", "color": "#FF1744"},
|
||||
{"id": "gran_fondo", "name": "Gran Fondo", "icon": "\U0001F3C6", "color": "#FF6D00"},
|
||||
{"id": "group_ride", "name": "Group Ride", "icon": "\U0001F6B4", "color": "#2979FF"},
|
||||
{"id": "time_trial", "name": "Time Trial", "icon": "\u23F1\uFE0F", "color": "#AA00FF"},
|
||||
{"id": "century", "name": "Century", "icon": "\U0001F5FA\uFE0F", "color": "#00C853"},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(types)
|
||||
}
|
||||
18
internal/event/model.go
Normal file
18
internal/event/model.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package event
|
||||
|
||||
import "time"
|
||||
|
||||
type Event struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
EventDate time.Time `gorm:"not null;index" json:"event_date"`
|
||||
EventType string `gorm:"not null" json:"event_type"`
|
||||
Distance float64 `gorm:"default:0" json:"distance"`
|
||||
Priority string `gorm:"default:'C'" json:"priority"`
|
||||
Location string `gorm:"default:''" json:"location"`
|
||||
Notes string `gorm:"default:''" json:"notes"`
|
||||
URL string `gorm:"default:''" json:"url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
65
internal/event/repository.go
Normal file
65
internal/event/repository.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"rideaware/pkg/database"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Repository struct{}
|
||||
|
||||
func NewRepository() *Repository {
|
||||
return &Repository{}
|
||||
}
|
||||
|
||||
func (r *Repository) CreateEvent(event *Event) error {
|
||||
return database.DB.Create(event).Error
|
||||
}
|
||||
|
||||
func (r *Repository) GetEventByID(id, userID uint) (*Event, error) {
|
||||
var event Event
|
||||
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&event).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("event not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &event, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetUserEvents(userID uint) ([]Event, error) {
|
||||
var events []Event
|
||||
if err := database.DB.Where("user_id = ?", userID).Order("event_date ASC").Find(&events).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetEventsByDateRange(userID uint, start, end time.Time) ([]Event, error) {
|
||||
var events []Event
|
||||
if err := database.DB.Where("user_id = ? AND event_date BETWEEN ? AND ?", userID, start, end).
|
||||
Order("event_date ASC").Find(&events).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetUpcomingEvents(userID uint, limit int) ([]Event, error) {
|
||||
var events []Event
|
||||
if err := database.DB.Where("user_id = ? AND event_date >= ?", userID, time.Now()).
|
||||
Order("event_date ASC").Limit(limit).Find(&events).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateEvent(event *Event) error {
|
||||
return database.DB.Save(event).Error
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteEvent(id, userID uint) error {
|
||||
return database.DB.Where("id = ? AND user_id = ?", id, userID).Delete(&Event{}).Error
|
||||
}
|
||||
105
internal/event/service.go
Normal file
105
internal/event/service.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{repo: NewRepository()}
|
||||
}
|
||||
|
||||
func (s *Service) CreateEvent(userID uint, name string, eventDate time.Time, eventType, priority, location, notes, url string, distance float64) (*Event, error) {
|
||||
if name == "" {
|
||||
return nil, errors.New("name is required")
|
||||
}
|
||||
if eventDate.IsZero() {
|
||||
return nil, errors.New("event_date is required")
|
||||
}
|
||||
if !isValidEventType(eventType) {
|
||||
return nil, errors.New("invalid event_type")
|
||||
}
|
||||
if !isValidPriority(priority) {
|
||||
priority = "C"
|
||||
}
|
||||
|
||||
event := &Event{
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
EventDate: eventDate,
|
||||
EventType: eventType,
|
||||
Distance: distance,
|
||||
Priority: priority,
|
||||
Location: location,
|
||||
Notes: notes,
|
||||
URL: url,
|
||||
}
|
||||
|
||||
if err := s.repo.CreateEvent(event); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetUserEvents(userID uint) ([]Event, error) {
|
||||
return s.repo.GetUserEvents(userID)
|
||||
}
|
||||
|
||||
func (s *Service) GetUpcomingEvents(userID uint, limit int) ([]Event, error) {
|
||||
return s.repo.GetUpcomingEvents(userID, limit)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateEvent(id, userID uint, name string, eventDate time.Time, eventType, priority, location, notes, url string, distance float64) (*Event, error) {
|
||||
event, err := s.repo.GetEventByID(id, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if name != "" {
|
||||
event.Name = name
|
||||
}
|
||||
if !eventDate.IsZero() {
|
||||
event.EventDate = eventDate
|
||||
}
|
||||
if eventType != "" {
|
||||
if !isValidEventType(eventType) {
|
||||
return nil, errors.New("invalid event_type")
|
||||
}
|
||||
event.EventType = eventType
|
||||
}
|
||||
if priority != "" {
|
||||
if !isValidPriority(priority) {
|
||||
return nil, errors.New("invalid priority")
|
||||
}
|
||||
event.Priority = priority
|
||||
}
|
||||
event.Location = location
|
||||
event.Notes = notes
|
||||
event.URL = url
|
||||
event.Distance = distance
|
||||
|
||||
if err := s.repo.UpdateEvent(event); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteEvent(id, userID uint) error {
|
||||
return s.repo.DeleteEvent(id, userID)
|
||||
}
|
||||
|
||||
func isValidEventType(t string) bool {
|
||||
valid := map[string]bool{
|
||||
"race": true, "gran_fondo": true, "group_ride": true,
|
||||
"time_trial": true, "century": true,
|
||||
}
|
||||
return valid[t]
|
||||
}
|
||||
|
||||
func isValidPriority(p string) bool {
|
||||
return p == "A" || p == "B" || p == "C"
|
||||
}
|
||||
@@ -29,17 +29,23 @@ func EncodeFITWorkout(w *workout.Workout, userFTP int) ([]byte, error) {
|
||||
wktFile.FileId.
|
||||
SetType(typedef.FileWorkout).
|
||||
SetManufacturer(typedef.ManufacturerDevelopment).
|
||||
SetProduct(1).
|
||||
SetTimeCreated(time.Now())
|
||||
SetProduct(0).
|
||||
SetTimeCreated(time.Now()).
|
||||
SetSerialNumber(uint32(time.Now().Unix()))
|
||||
|
||||
name := w.WorkoutData.Name
|
||||
if name == "" {
|
||||
name = w.Title
|
||||
}
|
||||
// Truncate name if too long (FIT spec supports up to 50 chars)
|
||||
if len(name) > 50 {
|
||||
name = name[:50]
|
||||
}
|
||||
|
||||
wktFile.Workout = mesgdef.NewWorkout(nil).
|
||||
SetWktName(name).
|
||||
SetSport(typedef.SportCycling).
|
||||
SetSubSport(typedef.SubSportGeneric).
|
||||
SetNumValidSteps(uint16(len(w.WorkoutData.Segments)))
|
||||
|
||||
steps := make([]*mesgdef.WorkoutStep, 0, len(w.WorkoutData.Segments))
|
||||
@@ -95,11 +101,20 @@ func segmentToFITStep(seg workout.WorkoutSegment, index uint16, userFTP int) *me
|
||||
case "freeride":
|
||||
step.SetIntensity(typedef.IntensityActive)
|
||||
step.SetTargetType(typedef.WktStepTargetOpen)
|
||||
step.SetTargetValue(0)
|
||||
step.SetWktStepName("Free Ride")
|
||||
|
||||
case "rest":
|
||||
step.SetIntensity(typedef.IntensityRest)
|
||||
step.SetTargetType(typedef.WktStepTargetOpen)
|
||||
step.SetTargetValue(0)
|
||||
step.SetWktStepName("Rest")
|
||||
|
||||
default:
|
||||
step.SetIntensity(typedef.IntensityActive)
|
||||
step.SetTargetType(typedef.WktStepTargetOpen)
|
||||
step.SetTargetValue(0)
|
||||
step.SetWktStepName("Active")
|
||||
}
|
||||
|
||||
return step
|
||||
@@ -110,6 +125,7 @@ func segmentToFITStep(seg workout.WorkoutSegment, index uint16, userFTP int) *me
|
||||
func setPowerTarget(step *mesgdef.WorkoutStep, seg workout.WorkoutSegment, userFTP int) {
|
||||
if seg.PowerLow == 0 && seg.PowerHigh == 0 && seg.Power == 0 {
|
||||
step.SetTargetType(typedef.WktStepTargetOpen)
|
||||
step.SetTargetValue(0)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -117,13 +133,34 @@ func setPowerTarget(step *mesgdef.WorkoutStep, seg workout.WorkoutSegment, userF
|
||||
step.SetTargetValue(0) // 0 = custom range
|
||||
|
||||
if seg.PowerLow != 0 || seg.PowerHigh != 0 {
|
||||
low := uint32(float64(userFTP)*seg.PowerLow) + 1000
|
||||
high := uint32(float64(userFTP)*seg.PowerHigh) + 1000
|
||||
step.SetCustomTargetValueLow(low)
|
||||
step.SetCustomTargetValueHigh(high)
|
||||
lowWatts := uint32(float64(userFTP) * seg.PowerLow)
|
||||
highWatts := uint32(float64(userFTP) * seg.PowerHigh)
|
||||
|
||||
// Ensure we have a valid range (low < high)
|
||||
if lowWatts == highWatts {
|
||||
// Add a small tolerance range (±5W) around the target
|
||||
if lowWatts < 5 {
|
||||
lowWatts = 1
|
||||
highWatts = lowWatts + 5
|
||||
} else {
|
||||
lowWatts = lowWatts - 5
|
||||
highWatts = highWatts + 5
|
||||
}
|
||||
}
|
||||
|
||||
step.SetCustomTargetValueLow(lowWatts + 1000)
|
||||
step.SetCustomTargetValueHigh(highWatts + 1000)
|
||||
} else if seg.Power != 0 {
|
||||
watts := uint32(float64(userFTP) * seg.Power)
|
||||
step.SetCustomTargetValueLow(watts - 10 + 1000)
|
||||
// Ensure low value doesn't go below 1000 (minimum is 1 watt)
|
||||
lowOffset := uint32(10)
|
||||
if watts < 10 {
|
||||
lowOffset = watts - 1
|
||||
if lowOffset == 0 {
|
||||
lowOffset = 0
|
||||
}
|
||||
}
|
||||
step.SetCustomTargetValueLow(watts - lowOffset + 1000)
|
||||
step.SetCustomTargetValueHigh(watts + 10 + 1000)
|
||||
}
|
||||
}
|
||||
@@ -132,6 +169,7 @@ func setPowerTarget(step *mesgdef.WorkoutStep, seg workout.WorkoutSegment, userF
|
||||
func setPowerTargetSteady(step *mesgdef.WorkoutStep, seg workout.WorkoutSegment, userFTP int) {
|
||||
if seg.Power == 0 {
|
||||
step.SetTargetType(typedef.WktStepTargetOpen)
|
||||
step.SetTargetValue(0)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -139,6 +177,14 @@ func setPowerTargetSteady(step *mesgdef.WorkoutStep, seg workout.WorkoutSegment,
|
||||
step.SetTargetValue(0)
|
||||
|
||||
watts := uint32(float64(userFTP) * seg.Power)
|
||||
step.SetCustomTargetValueLow(watts - 10 + 1000)
|
||||
// Ensure low value doesn't go below 1000 (minimum is 1 watt)
|
||||
lowOffset := uint32(10)
|
||||
if watts < 10 {
|
||||
lowOffset = watts - 1
|
||||
if lowOffset == 0 {
|
||||
lowOffset = 0
|
||||
}
|
||||
}
|
||||
step.SetCustomTargetValueLow(watts - lowOffset + 1000)
|
||||
step.SetCustomTargetValueHigh(watts + 10 + 1000)
|
||||
}
|
||||
|
||||
210
internal/goal/handler.go
Normal file
210
internal/goal/handler.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package goal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"rideaware/internal/config"
|
||||
"rideaware/internal/middleware"
|
||||
)
|
||||
|
||||
type Handler struct{}
|
||||
|
||||
func NewHandler() *Handler {
|
||||
return &Handler{}
|
||||
}
|
||||
|
||||
// CreateGoal POST /api/protected/goals
|
||||
func (h *Handler) CreateGoal(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
GoalType string `json:"goal_type"`
|
||||
TargetValue float64 `json:"target_value"`
|
||||
TargetDate *time.Time `json:"target_date"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Title == "" {
|
||||
writeError(w, http.StatusBadRequest, "title is required")
|
||||
return
|
||||
}
|
||||
|
||||
if req.TargetValue <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "target_value must be greater than 0")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate goal type
|
||||
gType := GoalType(req.GoalType)
|
||||
switch gType {
|
||||
case GoalDistance, GoalFrequency, GoalPower, GoalWeight, GoalCustom, "":
|
||||
if gType == "" {
|
||||
gType = GoalCustom
|
||||
}
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "invalid goal_type")
|
||||
return
|
||||
}
|
||||
|
||||
goal := &Goal{
|
||||
UserID: claims.UserID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
GoalType: gType,
|
||||
TargetValue: req.TargetValue,
|
||||
TargetDate: req.TargetDate,
|
||||
Status: StatusActive,
|
||||
}
|
||||
|
||||
repo := NewRepository()
|
||||
if err := repo.Create(goal); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create goal")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, goal)
|
||||
}
|
||||
|
||||
// GetGoals GET /api/protected/goals?status=active
|
||||
func (h *Handler) GetGoals(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
|
||||
statusFilter := r.URL.Query().Get("status")
|
||||
repo := NewRepository()
|
||||
|
||||
var goals []Goal
|
||||
var err error
|
||||
|
||||
if statusFilter != "" {
|
||||
goals, err = repo.ListByStatus(claims.UserID, GoalStatus(statusFilter))
|
||||
} else {
|
||||
goals, err = repo.List(claims.UserID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to fetch goals")
|
||||
return
|
||||
}
|
||||
|
||||
if goals == nil {
|
||||
goals = []Goal{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, goals)
|
||||
}
|
||||
|
||||
// UpdateGoal PUT /api/protected/goals?id=X
|
||||
func (h *Handler) UpdateGoal(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
|
||||
idStr := r.URL.Query().Get("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid goal id")
|
||||
return
|
||||
}
|
||||
|
||||
repo := NewRepository()
|
||||
goal, err := repo.GetByID(uint(id), claims.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "goal not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
GoalType string `json:"goal_type"`
|
||||
TargetValue *float64 `json:"target_value"`
|
||||
CurrentValue *float64 `json:"current_value"`
|
||||
TargetDate *time.Time `json:"target_date"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Title != "" {
|
||||
goal.Title = req.Title
|
||||
}
|
||||
if req.Description != "" {
|
||||
goal.Description = req.Description
|
||||
}
|
||||
if req.GoalType != "" {
|
||||
gType := GoalType(req.GoalType)
|
||||
switch gType {
|
||||
case GoalDistance, GoalFrequency, GoalPower, GoalWeight, GoalCustom:
|
||||
goal.GoalType = gType
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "invalid goal_type")
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.TargetValue != nil {
|
||||
goal.TargetValue = *req.TargetValue
|
||||
}
|
||||
if req.CurrentValue != nil {
|
||||
goal.CurrentValue = *req.CurrentValue
|
||||
}
|
||||
if req.TargetDate != nil {
|
||||
goal.TargetDate = req.TargetDate
|
||||
}
|
||||
if req.Status != "" {
|
||||
switch GoalStatus(req.Status) {
|
||||
case StatusActive, StatusCompleted, StatusArchived:
|
||||
goal.Status = GoalStatus(req.Status)
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "invalid status")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := repo.Update(goal); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update goal")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, goal)
|
||||
}
|
||||
|
||||
// DeleteGoal DELETE /api/protected/goals?id=X
|
||||
func (h *Handler) DeleteGoal(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
|
||||
idStr := r.URL.Query().Get("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid goal id")
|
||||
return
|
||||
}
|
||||
|
||||
repo := NewRepository()
|
||||
if err := repo.Delete(uint(id), claims.UserID); err != nil {
|
||||
writeError(w, http.StatusNotFound, "goal not found")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
42
internal/goal/model.go
Normal file
42
internal/goal/model.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package goal
|
||||
|
||||
import "time"
|
||||
|
||||
// GoalType defines categories of goals users can set.
|
||||
type GoalType string
|
||||
|
||||
const (
|
||||
GoalDistance GoalType = "distance"
|
||||
GoalFrequency GoalType = "frequency"
|
||||
GoalPower GoalType = "power"
|
||||
GoalWeight GoalType = "weight"
|
||||
GoalCustom GoalType = "custom"
|
||||
)
|
||||
|
||||
// GoalStatus tracks the lifecycle of a goal.
|
||||
type GoalStatus string
|
||||
|
||||
const (
|
||||
StatusActive GoalStatus = "active"
|
||||
StatusCompleted GoalStatus = "completed"
|
||||
StatusArchived GoalStatus = "archived"
|
||||
)
|
||||
|
||||
type Goal struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
Title string `gorm:"not null" json:"title"`
|
||||
Description string `gorm:"default:''" json:"description"`
|
||||
GoalType GoalType `gorm:"default:'custom'" json:"goal_type"`
|
||||
TargetValue float64 `gorm:"not null;default:0" json:"target_value"`
|
||||
CurrentValue float64 `gorm:"default:0" json:"current_value"`
|
||||
TargetDate *time.Time `json:"target_date,omitempty"`
|
||||
Status GoalStatus `gorm:"default:'active'" json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Goal) TableName() string {
|
||||
return "goals"
|
||||
}
|
||||
|
||||
49
internal/goal/repository.go
Normal file
49
internal/goal/repository.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package goal
|
||||
|
||||
import (
|
||||
"rideaware/pkg/database"
|
||||
)
|
||||
|
||||
type Repository struct{}
|
||||
|
||||
func NewRepository() *Repository {
|
||||
return &Repository{}
|
||||
}
|
||||
|
||||
func (r *Repository) Create(goal *Goal) error {
|
||||
return database.DB.Create(goal).Error
|
||||
}
|
||||
|
||||
func (r *Repository) GetByID(id, userID uint) (*Goal, error) {
|
||||
var g Goal
|
||||
err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&g).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
func (r *Repository) List(userID uint) ([]Goal, error) {
|
||||
var goals []Goal
|
||||
err := database.DB.Where("user_id = ?", userID).
|
||||
Order("created_at desc").
|
||||
Find(&goals).Error
|
||||
return goals, err
|
||||
}
|
||||
|
||||
func (r *Repository) ListByStatus(userID uint, status GoalStatus) ([]Goal, error) {
|
||||
var goals []Goal
|
||||
err := database.DB.Where("user_id = ? AND status = ?", userID, status).
|
||||
Order("created_at desc").
|
||||
Find(&goals).Error
|
||||
return goals, err
|
||||
}
|
||||
|
||||
func (r *Repository) Update(goal *Goal) error {
|
||||
return database.DB.Save(goal).Error
|
||||
}
|
||||
|
||||
func (r *Repository) Delete(id, userID uint) error {
|
||||
return database.DB.Where("id = ? AND user_id = ?", id, userID).Delete(&Goal{}).Error
|
||||
}
|
||||
|
||||
308
internal/integration/intervals_client.go
Normal file
308
internal/integration/intervals_client.go
Normal file
@@ -0,0 +1,308 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rideaware/internal/config"
|
||||
"rideaware/internal/workout"
|
||||
)
|
||||
|
||||
const intervalsBaseURL = "https://intervals.icu/api/v1"
|
||||
const intervalsProvider = "intervals_icu"
|
||||
|
||||
type IntervalsClient struct {
|
||||
oauthService *OAuthService
|
||||
workoutRepo *workout.Repository
|
||||
}
|
||||
|
||||
func NewIntervalsClient() *IntervalsClient {
|
||||
return &IntervalsClient{
|
||||
oauthService: NewOAuthService(),
|
||||
workoutRepo: workout.NewRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
// SaveApiKey stores the user's Intervals.icu API key encrypted in the oauth_connections table.
|
||||
func (c *IntervalsClient) SaveApiKey(userID uint, apiKey string) error {
|
||||
// Use a far-future expiry so GetValidToken never marks it expired
|
||||
tokenResp := &TokenResponse{
|
||||
AccessToken: apiKey,
|
||||
ExpiresIn: 100 * 365 * 24 * 3600,
|
||||
}
|
||||
return c.oauthService.SaveConnection(userID, intervalsProvider, tokenResp)
|
||||
}
|
||||
|
||||
// getApiKey retrieves and decrypts the stored API key for a user.
|
||||
func (c *IntervalsClient) getApiKey(userID uint) (string, error) {
|
||||
conn, err := c.oauthService.repo.GetConnection(userID, intervalsProvider)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("intervals.icu not connected")
|
||||
}
|
||||
if conn.Status != "active" {
|
||||
return "", fmt.Errorf("intervals.icu connection is %s, please reconnect", conn.Status)
|
||||
}
|
||||
return Decrypt(conn.AccessToken, config.OAuth.EncryptionKey)
|
||||
}
|
||||
|
||||
// intervalsRequest performs an authenticated request to the Intervals.icu API.
|
||||
func (c *IntervalsClient) intervalsRequest(method, path string, apiKey string, body interface{}) (*http.Response, error) {
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, intervalsBaseURL+path, reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.SetBasicAuth("API_KEY", apiKey)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
return http.DefaultClient.Do(req)
|
||||
}
|
||||
|
||||
// ---- Workout push structures ----
|
||||
|
||||
type intervalsEventPayload struct {
|
||||
StartDateLocal string `json:"start_date_local"`
|
||||
Category string `json:"category"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Type string `json:"type"`
|
||||
MovingTime int `json:"moving_time"`
|
||||
WorkoutDoc *intervalsWorkoutDoc `json:"workout_doc,omitempty"`
|
||||
}
|
||||
|
||||
type intervalsWorkoutDoc struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Duration int `json:"duration"`
|
||||
Target string `json:"target"`
|
||||
Steps []intervalsStep `json:"steps"`
|
||||
}
|
||||
|
||||
type intervalsStep struct {
|
||||
Duration int `json:"duration"`
|
||||
Intensity string `json:"intensity"`
|
||||
Warmup bool `json:"warmup,omitempty"`
|
||||
Cooldown bool `json:"cooldown,omitempty"`
|
||||
Power *intervalsPowerValue `json:"power,omitempty"`
|
||||
Cadence *intervalsCadenceValue `json:"cadence,omitempty"`
|
||||
}
|
||||
|
||||
type intervalsPowerValue struct {
|
||||
Value float64 `json:"value,omitempty"`
|
||||
Start float64 `json:"start,omitempty"`
|
||||
End float64 `json:"end,omitempty"`
|
||||
Units string `json:"units"`
|
||||
}
|
||||
|
||||
type intervalsCadenceValue struct {
|
||||
Value float64 `json:"value"`
|
||||
Units string `json:"units"`
|
||||
}
|
||||
|
||||
func segmentToStep(seg workout.WorkoutSegment) intervalsStep {
|
||||
t := strings.ToLower(seg.Type)
|
||||
step := intervalsStep{
|
||||
Duration: seg.Duration,
|
||||
}
|
||||
|
||||
switch t {
|
||||
case "warmup", "warm_up":
|
||||
step.Intensity = "warmup"
|
||||
step.Warmup = true
|
||||
case "cooldown", "cool_down":
|
||||
step.Intensity = "cooldown"
|
||||
step.Cooldown = true
|
||||
case "recovery":
|
||||
step.Intensity = "recovery"
|
||||
case "interval":
|
||||
step.Intensity = "interval"
|
||||
default:
|
||||
step.Intensity = "active"
|
||||
}
|
||||
|
||||
// Power
|
||||
low := seg.PowerLow
|
||||
high := seg.PowerHigh
|
||||
single := seg.Power
|
||||
if single > 0 {
|
||||
step.Power = &intervalsPowerValue{Value: single, Units: "%ftp"}
|
||||
} else if low > 0 || high > 0 {
|
||||
if low == high {
|
||||
step.Power = &intervalsPowerValue{Value: low, Units: "%ftp"}
|
||||
} else {
|
||||
step.Power = &intervalsPowerValue{Start: low, End: high, Units: "%ftp"}
|
||||
}
|
||||
}
|
||||
|
||||
// Cadence
|
||||
if seg.Cadence > 0 {
|
||||
step.Cadence = &intervalsCadenceValue{Value: float64(seg.Cadence), Units: "rpm"}
|
||||
}
|
||||
|
||||
return step
|
||||
}
|
||||
|
||||
// PushWorkout pushes a planned workout to the user's Intervals.icu calendar.
|
||||
func (c *IntervalsClient) PushWorkout(workoutID, userID uint) error {
|
||||
apiKey, err := c.getApiKey(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w, err := c.workoutRepo.GetWorkoutByID(workoutID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("workout not found: %w", err)
|
||||
}
|
||||
|
||||
// Build workout_doc from segments
|
||||
var doc *intervalsWorkoutDoc
|
||||
if len(w.WorkoutData.Segments) > 0 {
|
||||
steps := make([]intervalsStep, 0, len(w.WorkoutData.Segments))
|
||||
for _, seg := range w.WorkoutData.Segments {
|
||||
steps = append(steps, segmentToStep(seg))
|
||||
}
|
||||
doc = &intervalsWorkoutDoc{
|
||||
Description: w.Description,
|
||||
Duration: w.Duration,
|
||||
Target: "POWER",
|
||||
Steps: steps,
|
||||
}
|
||||
}
|
||||
|
||||
payload := intervalsEventPayload{
|
||||
StartDateLocal: w.ScheduledDate.Format("2006-01-02") + "T00:00:00",
|
||||
Category: "WORKOUT",
|
||||
Name: w.Title,
|
||||
Description: w.Description,
|
||||
Type: "Ride",
|
||||
MovingTime: w.Duration,
|
||||
WorkoutDoc: doc,
|
||||
}
|
||||
|
||||
resp, err := c.intervalsRequest("POST", "/athlete/0/events", apiKey, payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to push workout to Intervals.icu: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("Intervals.icu API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Activity sync structures ----
|
||||
|
||||
type intervalsActivity struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
StartDateLocal string `json:"start_date_local"`
|
||||
MovingTime int `json:"moving_time"`
|
||||
Distance float64 `json:"distance"`
|
||||
AverageWatts int `json:"average_watts"`
|
||||
AverageHeartrate int `json:"average_heartrate"`
|
||||
MaxWatts int `json:"max_watts"`
|
||||
MaxHeartrate int `json:"max_heartrate"`
|
||||
Calories int `json:"calories"`
|
||||
TotalElevationGain int `json:"total_elevation_gain"`
|
||||
}
|
||||
|
||||
// SyncActivities fetches recent completed activities from Intervals.icu and imports them.
|
||||
// Returns the number of new activities imported.
|
||||
func (c *IntervalsClient) SyncActivities(userID uint, days int) (int, error) {
|
||||
apiKey, err := c.getApiKey(userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if days <= 0 || days > 365 {
|
||||
days = 30
|
||||
}
|
||||
|
||||
oldest := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
|
||||
newest := time.Now().Format("2006-01-02")
|
||||
|
||||
resp, err := c.intervalsRequest("GET",
|
||||
fmt.Sprintf("/athlete/0/activities?oldest=%s&newest=%s", oldest, newest),
|
||||
apiKey, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to fetch activities from Intervals.icu: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return 0, fmt.Errorf("Intervals.icu API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var activities []intervalsActivity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&activities); err != nil {
|
||||
return 0, fmt.Errorf("failed to parse Intervals.icu activities: %w", err)
|
||||
}
|
||||
|
||||
imported := 0
|
||||
for _, act := range activities {
|
||||
// Parse date from start_date_local (format: "2026-01-15T08:30:00")
|
||||
t, err := time.Parse("2006-01-02T15:04:05", act.StartDateLocal)
|
||||
if err != nil {
|
||||
// Try alternate format without time
|
||||
t, err = time.Parse("2006-01-02", act.StartDateLocal[:10])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
dateStr := t.Format("2006-01-02")
|
||||
|
||||
// Skip if a completed workout already exists on this date
|
||||
existing, _ := c.workoutRepo.GetCompletedWorkoutOnDate(userID, dateStr)
|
||||
if existing != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Map activity type to RideAware type
|
||||
workoutType := act.Type
|
||||
if workoutType == "" {
|
||||
workoutType = "Ride"
|
||||
}
|
||||
|
||||
w := &workout.Workout{
|
||||
UserID: userID,
|
||||
Title: act.Name,
|
||||
Type: workoutType,
|
||||
Status: "completed",
|
||||
ScheduledDate: t,
|
||||
Duration: act.MovingTime,
|
||||
Distance: act.Distance / 1000, // metres → km
|
||||
AvgPower: act.AverageWatts,
|
||||
AvgHR: act.AverageHeartrate,
|
||||
MaxPower: act.MaxWatts,
|
||||
MaxHR: act.MaxHeartrate,
|
||||
CaloriesBurned: act.Calories,
|
||||
ElevGain: act.TotalElevationGain,
|
||||
}
|
||||
|
||||
if err := c.workoutRepo.CreateWorkout(w); err == nil {
|
||||
imported++
|
||||
}
|
||||
}
|
||||
|
||||
return imported, nil
|
||||
}
|
||||
167
internal/integration/intervals_handler.go
Normal file
167
internal/integration/intervals_handler.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"rideaware/internal/config"
|
||||
"rideaware/internal/middleware"
|
||||
)
|
||||
|
||||
type IntervalsHandler struct {
|
||||
client *IntervalsClient
|
||||
oauthService *OAuthService
|
||||
}
|
||||
|
||||
func NewIntervalsHandler() *IntervalsHandler {
|
||||
return &IntervalsHandler{
|
||||
client: NewIntervalsClient(),
|
||||
oauthService: NewOAuthService(),
|
||||
}
|
||||
}
|
||||
|
||||
// SaveApiKey POST /api/protected/intervals/connect
|
||||
func (h *IntervalsHandler) SaveApiKey(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
if claims == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.APIKey == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "api_key is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.client.SaveApiKey(claims.UserID, req.APIKey); err != nil {
|
||||
log.Printf("Intervals.icu save key error: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "failed to save API key"})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Intervals.icu connected"})
|
||||
}
|
||||
|
||||
// ConnectionStatus GET /api/protected/intervals/status
|
||||
func (h *IntervalsHandler) ConnectionStatus(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
if claims == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
status, err := h.oauthService.GetConnectionStatus(claims.UserID, intervalsProvider)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
|
||||
// Disconnect DELETE /api/protected/intervals/disconnect
|
||||
func (h *IntervalsHandler) Disconnect(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
if claims == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.oauthService.Disconnect(claims.UserID, intervalsProvider); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Intervals.icu disconnected"})
|
||||
}
|
||||
|
||||
// PushWorkout POST /api/protected/workouts/push/intervals?id=X
|
||||
func (h *IntervalsHandler) PushWorkout(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
if claims == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
idStr := r.URL.Query().Get("id")
|
||||
if idStr == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "workout id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "invalid workout id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.client.PushWorkout(uint(id), claims.UserID); err != nil {
|
||||
log.Printf("Intervals.icu push error: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "workout pushed to Intervals.icu"})
|
||||
}
|
||||
|
||||
// SyncActivities POST /api/protected/intervals/sync?days=30
|
||||
func (h *IntervalsHandler) SyncActivities(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
if claims == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
days := 30
|
||||
if dStr := r.URL.Query().Get("days"); dStr != "" {
|
||||
if d, err := strconv.Atoi(dStr); err == nil && d > 0 {
|
||||
days = d
|
||||
}
|
||||
}
|
||||
|
||||
imported, err := h.client.SyncActivities(claims.UserID, days)
|
||||
if err != nil {
|
||||
log.Printf("Intervals.icu sync error: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]int{"imported": imported})
|
||||
}
|
||||
@@ -17,6 +17,35 @@ func NewAuthMiddleware() *AuthMiddleware {
|
||||
return &AuthMiddleware{}
|
||||
}
|
||||
|
||||
// RequireRole returns middleware that checks for a specific user role.
|
||||
// It must be used after ProtectedRoute (or any middleware that sets the user context).
|
||||
func (am *AuthMiddleware) RequireRole(role string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(UserContextKey).(*config.CustomClaims)
|
||||
if !ok || claims == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if claims.Role != role {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "insufficient permissions",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (am *AuthMiddleware) ProtectedRoute(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
|
||||
63
internal/nutrition/handler.go
Normal file
63
internal/nutrition/handler.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package nutrition
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"rideaware/internal/config"
|
||||
"rideaware/internal/middleware"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler() *Handler {
|
||||
return &Handler{
|
||||
service: NewService(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetTargets GET /api/protected/nutrition/targets
|
||||
func (h *Handler) GetTargets(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
if claims == nil {
|
||||
respondError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
targets, err := h.service.GetTargets(claims.UserID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to calculate targets")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, targets)
|
||||
}
|
||||
|
||||
// GetWeekly GET /api/protected/nutrition/weekly
|
||||
func (h *Handler) GetWeekly(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
if claims == nil {
|
||||
respondError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
weekly, err := h.service.GetWeeklyNutrition(claims.UserID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to calculate weekly nutrition")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, weekly)
|
||||
}
|
||||
|
||||
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func respondError(w http.ResponseWriter, status int, message string) {
|
||||
respondJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
290
internal/nutrition/service.go
Normal file
290
internal/nutrition/service.go
Normal file
@@ -0,0 +1,290 @@
|
||||
package nutrition
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"rideaware/internal/stats"
|
||||
"rideaware/internal/user"
|
||||
"rideaware/internal/workout"
|
||||
"rideaware/pkg/database"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
userRepo *user.Repository
|
||||
statsRepo *stats.Repository
|
||||
workoutRepo *workout.Repository
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{
|
||||
userRepo: user.NewRepository(),
|
||||
statsRepo: stats.NewRepository(),
|
||||
workoutRepo: workout.NewRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
// NutritionTargets contains calculated daily nutrition targets
|
||||
type NutritionTargets struct {
|
||||
BMR int `json:"bmr"`
|
||||
TDEE int `json:"tdee"`
|
||||
DailyCalories int `json:"daily_calories"`
|
||||
GoalAdjustment int `json:"goal_adjustment"`
|
||||
Protein int `json:"protein_g"`
|
||||
Carbs int `json:"carbs_g"`
|
||||
Fat int `json:"fat_g"`
|
||||
NutritionGoal string `json:"nutrition_goal"`
|
||||
DietaryPref string `json:"dietary_preference"`
|
||||
CurrentWeight float64 `json:"current_weight"`
|
||||
TargetWeight float64 `json:"target_weight"`
|
||||
IsConfigured bool `json:"is_configured"`
|
||||
}
|
||||
|
||||
// DailyNutrition represents one day's nutrition data
|
||||
type DailyNutrition struct {
|
||||
Date string `json:"date"`
|
||||
BaseCalories int `json:"base_calories"`
|
||||
WorkoutCalories int `json:"workout_calories"`
|
||||
TotalTarget int `json:"total_target"`
|
||||
Protein int `json:"protein_g"`
|
||||
Carbs int `json:"carbs_g"`
|
||||
Fat int `json:"fat_g"`
|
||||
IsTrainingDay bool `json:"is_training_day"`
|
||||
WorkoutTitle string `json:"workout_title,omitempty"`
|
||||
}
|
||||
|
||||
// WeeklyNutrition contains a week of daily nutrition data
|
||||
type WeeklyNutrition struct {
|
||||
Days []DailyNutrition `json:"days"`
|
||||
AvgCalories int `json:"avg_calories"`
|
||||
AvgProtein int `json:"avg_protein_g"`
|
||||
AvgCarbs int `json:"avg_carbs_g"`
|
||||
AvgFat int `json:"avg_fat_g"`
|
||||
TotalWorkoutCal int `json:"total_workout_calories"`
|
||||
}
|
||||
|
||||
// CalculateBMR uses Mifflin-St Jeor equation
|
||||
func CalculateBMR(weight, height float64, age int, gender string) int {
|
||||
if weight <= 0 || height <= 0 || age <= 0 {
|
||||
return 0
|
||||
}
|
||||
bmr := 10.0*weight + 6.25*height - 5.0*float64(age)
|
||||
if gender == "female" {
|
||||
bmr -= 161
|
||||
} else {
|
||||
bmr += 5
|
||||
}
|
||||
return int(math.Round(bmr))
|
||||
}
|
||||
|
||||
// CalculateTDEE applies activity multiplier to BMR
|
||||
func CalculateTDEE(bmr int, activityLevel string) int {
|
||||
multipliers := map[string]float64{
|
||||
"sedentary": 1.2,
|
||||
"lightly_active": 1.375,
|
||||
"active": 1.55,
|
||||
"very_active": 1.725,
|
||||
}
|
||||
mult, ok := multipliers[activityLevel]
|
||||
if !ok {
|
||||
mult = 1.375 // default to lightly active
|
||||
}
|
||||
return int(math.Round(float64(bmr) * mult))
|
||||
}
|
||||
|
||||
// goalAdjustment returns calorie adjustment for the goal
|
||||
func goalAdjustment(goal string) int {
|
||||
switch goal {
|
||||
case "weight_loss":
|
||||
return -500
|
||||
case "performance":
|
||||
return 300
|
||||
default: // maintenance
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// macroSplit returns protein/carbs/fat percentages based on dietary preference
|
||||
func macroSplit(pref string) (protPct, carbPct, fatPct float64) {
|
||||
switch pref {
|
||||
case "high_carb":
|
||||
return 0.20, 0.60, 0.20
|
||||
case "high_protein":
|
||||
return 0.35, 0.40, 0.25
|
||||
case "keto":
|
||||
return 0.30, 0.10, 0.60
|
||||
default: // balanced
|
||||
return 0.25, 0.50, 0.25
|
||||
}
|
||||
}
|
||||
|
||||
// calculateMacros converts calories to grams of each macro
|
||||
func calculateMacros(calories int, pref string) (protein, carbs, fat int) {
|
||||
protPct, carbPct, fatPct := macroSplit(pref)
|
||||
protein = int(math.Round(float64(calories) * protPct / 4.0)) // 4 cal/g
|
||||
carbs = int(math.Round(float64(calories) * carbPct / 4.0)) // 4 cal/g
|
||||
fat = int(math.Round(float64(calories) * fatPct / 9.0)) // 9 cal/g
|
||||
return
|
||||
}
|
||||
|
||||
// GetTargets calculates nutrition targets for a user
|
||||
func (s *Service) GetTargets(userID uint) (*NutritionTargets, error) {
|
||||
userObj, err := s.userRepo.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profile := userObj.Profile
|
||||
if profile == nil {
|
||||
return &NutritionTargets{IsConfigured: false}, nil
|
||||
}
|
||||
|
||||
// Check if nutrition is configured
|
||||
if profile.Height <= 0 || profile.Age <= 0 || profile.Weight <= 0 || profile.Gender == "" {
|
||||
return &NutritionTargets{
|
||||
IsConfigured: false,
|
||||
CurrentWeight: profile.Weight,
|
||||
NutritionGoal: profile.NutritionGoal,
|
||||
}, nil
|
||||
}
|
||||
|
||||
bmr := CalculateBMR(profile.Weight, profile.Height, profile.Age, profile.Gender)
|
||||
activityLevel := profile.ActivityLevel
|
||||
if activityLevel == "" {
|
||||
activityLevel = "lightly_active"
|
||||
}
|
||||
tdee := CalculateTDEE(bmr, activityLevel)
|
||||
|
||||
goal := profile.NutritionGoal
|
||||
if goal == "" {
|
||||
goal = "maintenance"
|
||||
}
|
||||
adj := goalAdjustment(goal)
|
||||
dailyCal := tdee + adj
|
||||
|
||||
dietPref := profile.DietaryPref
|
||||
if dietPref == "" {
|
||||
dietPref = "balanced"
|
||||
}
|
||||
protein, carbs, fat := calculateMacros(dailyCal, dietPref)
|
||||
|
||||
return &NutritionTargets{
|
||||
BMR: bmr,
|
||||
TDEE: tdee,
|
||||
DailyCalories: dailyCal,
|
||||
GoalAdjustment: adj,
|
||||
Protein: protein,
|
||||
Carbs: carbs,
|
||||
Fat: fat,
|
||||
NutritionGoal: goal,
|
||||
DietaryPref: dietPref,
|
||||
CurrentWeight: profile.Weight,
|
||||
TargetWeight: profile.TargetWeight,
|
||||
IsConfigured: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetWeeklyNutrition returns 7 days of nutrition data with workout adjustments
|
||||
func (s *Service) GetWeeklyNutrition(userID uint) (*WeeklyNutrition, error) {
|
||||
targets, err := s.GetTargets(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !targets.IsConfigured {
|
||||
return &WeeklyNutrition{Days: []DailyNutrition{}}, nil
|
||||
}
|
||||
|
||||
// Get workouts for the next 7 days
|
||||
now := time.Now()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
endDate := today.AddDate(0, 0, 7)
|
||||
|
||||
workouts, err := s.workoutRepo.GetWorkoutsByDateRange(userID, today, endDate)
|
||||
if err != nil {
|
||||
workouts = []workout.Workout{}
|
||||
}
|
||||
|
||||
// Also get past 7 days for completed workout calories
|
||||
pastStart := today.AddDate(0, 0, -7)
|
||||
pastWorkouts, err := s.workoutRepo.GetWorkoutsByDateRange(userID, pastStart, today)
|
||||
if err != nil {
|
||||
pastWorkouts = []workout.Workout{}
|
||||
}
|
||||
allWorkouts := append(pastWorkouts, workouts...)
|
||||
|
||||
// Build workout map by date
|
||||
workoutMap := make(map[string][]workout.Workout)
|
||||
for _, w := range allWorkouts {
|
||||
dateStr := w.ScheduledDate.Format("2006-01-02")
|
||||
workoutMap[dateStr] = append(workoutMap[dateStr], w)
|
||||
}
|
||||
|
||||
// Get user FTP for calorie estimation
|
||||
var ftp int
|
||||
database.DB.Table("user_profiles").Select("ftp").Where("user_id = ?", userID).Scan(&ftp)
|
||||
|
||||
days := make([]DailyNutrition, 0, 7)
|
||||
totalWorkoutCal := 0
|
||||
|
||||
for i := 0; i < 7; i++ {
|
||||
date := today.AddDate(0, 0, i)
|
||||
dateStr := date.Format("2006-01-02")
|
||||
|
||||
dayWorkouts := workoutMap[dateStr]
|
||||
workoutCal := 0
|
||||
isTraining := false
|
||||
title := ""
|
||||
|
||||
for _, w := range dayWorkouts {
|
||||
if w.CaloriesBurned > 0 {
|
||||
workoutCal += w.CaloriesBurned
|
||||
} else if w.Duration > 0 && ftp > 0 && w.AvgPower > 0 {
|
||||
// Estimate: ~4 cal per kJ, kJ = watts * seconds / 1000
|
||||
kj := float64(w.AvgPower) * float64(w.Duration) / 1000.0
|
||||
workoutCal += int(kj * 4.0)
|
||||
} else if w.Duration > 0 {
|
||||
// Rough estimate: ~8 cal/min for cycling
|
||||
workoutCal += (w.Duration / 60) * 8
|
||||
}
|
||||
isTraining = true
|
||||
if title == "" {
|
||||
title = w.Title
|
||||
}
|
||||
}
|
||||
|
||||
totalTarget := targets.DailyCalories + workoutCal
|
||||
protein, carbs, fat := calculateMacros(totalTarget, targets.DietaryPref)
|
||||
|
||||
days = append(days, DailyNutrition{
|
||||
Date: dateStr,
|
||||
BaseCalories: targets.DailyCalories,
|
||||
WorkoutCalories: workoutCal,
|
||||
TotalTarget: totalTarget,
|
||||
Protein: protein,
|
||||
Carbs: carbs,
|
||||
Fat: fat,
|
||||
IsTrainingDay: isTraining,
|
||||
WorkoutTitle: title,
|
||||
})
|
||||
|
||||
totalWorkoutCal += workoutCal
|
||||
}
|
||||
|
||||
// Calculate averages
|
||||
avgCal, avgProt, avgCarb, avgFat := 0, 0, 0, 0
|
||||
for _, d := range days {
|
||||
avgCal += d.TotalTarget
|
||||
avgProt += d.Protein
|
||||
avgCarb += d.Carbs
|
||||
avgFat += d.Fat
|
||||
}
|
||||
|
||||
return &WeeklyNutrition{
|
||||
Days: days,
|
||||
AvgCalories: avgCal / 7,
|
||||
AvgProtein: avgProt / 7,
|
||||
AvgCarbs: avgCarb / 7,
|
||||
AvgFat: avgFat / 7,
|
||||
TotalWorkoutCal: totalWorkoutCal,
|
||||
}, nil
|
||||
}
|
||||
@@ -110,6 +110,73 @@ func (h *Handler) GetMonthlyStats(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(stats)
|
||||
}
|
||||
|
||||
// GetTrainingLoad GET /api/protected/stats/training-load?days=180
|
||||
func (h *Handler) GetTrainingLoad(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
if claims == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
days := 180
|
||||
if dStr := r.URL.Query().Get("days"); dStr != "" {
|
||||
if parsed, err := strconv.Atoi(dStr); err == nil {
|
||||
days = parsed
|
||||
}
|
||||
}
|
||||
|
||||
data, ftp, err := h.service.GetTrainingLoad(claims.UserID, days)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "failed to fetch training load"})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ftp": ftp,
|
||||
"daily_tss": data,
|
||||
})
|
||||
}
|
||||
|
||||
// GetPowerHistory GET /api/protected/stats/power-history?days=365
|
||||
func (h *Handler) GetPowerHistory(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
if claims == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
days := 365
|
||||
if dStr := r.URL.Query().Get("days"); dStr != "" {
|
||||
if parsed, err := strconv.Atoi(dStr); err == nil {
|
||||
days = parsed
|
||||
}
|
||||
}
|
||||
|
||||
data, err := h.service.GetPowerHistory(claims.UserID, days)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "failed to fetch power history"})
|
||||
return
|
||||
}
|
||||
|
||||
if data == nil {
|
||||
data = []PowerPoint{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// GetPersonalBests GET /api/protected/stats/personal-bests
|
||||
func (h *Handler) GetPersonalBests(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
|
||||
@@ -43,6 +43,21 @@ type PeriodStats struct {
|
||||
AvgHR float64 `json:"avg_hr"`
|
||||
}
|
||||
|
||||
// DailyTSS holds the computed TSS for a single day.
|
||||
type DailyTSS struct {
|
||||
Date string `json:"date"`
|
||||
TSS float64 `json:"tss"`
|
||||
}
|
||||
|
||||
// PowerPoint holds power data for a single completed workout.
|
||||
type PowerPoint struct {
|
||||
Date string `json:"date"`
|
||||
AvgPower int `json:"avg_power"`
|
||||
MaxPower int `json:"max_power"`
|
||||
Duration int `json:"duration"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// GetSummary returns overall ride statistics for completed workouts.
|
||||
func (r *Repository) GetSummary(userID uint) (*Summary, error) {
|
||||
var summary Summary
|
||||
@@ -120,6 +135,65 @@ func (r *Repository) GetMonthlyStats(userID uint, months int) ([]PeriodStats, er
|
||||
return stats, err
|
||||
}
|
||||
|
||||
// GetDailyTSS returns daily TSS values for the last N days.
|
||||
// TSS = (duration_seconds * (avg_power / FTP)^2 / 3600) * 100
|
||||
// Simplified: (duration * avg_power^2) / (FTP^2 * 36)
|
||||
func (r *Repository) GetDailyTSS(userID uint, ftp int, days int) ([]DailyTSS, error) {
|
||||
if ftp <= 0 {
|
||||
return []DailyTSS{}, nil
|
||||
}
|
||||
|
||||
cutoff := time.Now().AddDate(0, 0, -days)
|
||||
ftpFloat := float64(ftp)
|
||||
|
||||
var results []DailyTSS
|
||||
err := database.DB.Model(&workout.Workout{}).
|
||||
Select(`
|
||||
TO_CHAR(scheduled_date, 'YYYY-MM-DD') as date,
|
||||
COALESCE(SUM(
|
||||
CASE WHEN avg_power > 0 AND duration > 0
|
||||
THEN (duration::float8 * avg_power::float8 * avg_power::float8) / (? * ? * 36.0)
|
||||
ELSE 0
|
||||
END
|
||||
), 0) as tss
|
||||
`, ftpFloat, ftpFloat).
|
||||
Where("user_id = ? AND status = ? AND scheduled_date >= ?", userID, "completed", cutoff).
|
||||
Group("date").
|
||||
Order("date ASC").
|
||||
Scan(&results).Error
|
||||
|
||||
if results == nil {
|
||||
results = []DailyTSS{}
|
||||
}
|
||||
|
||||
return results, err
|
||||
}
|
||||
|
||||
// GetPowerHistory returns power data points for completed workouts in the last N days.
|
||||
func (r *Repository) GetPowerHistory(userID uint, days int) ([]PowerPoint, error) {
|
||||
cutoff := time.Now().AddDate(0, 0, -days)
|
||||
|
||||
var results []PowerPoint
|
||||
err := database.DB.Model(&workout.Workout{}).
|
||||
Select(`
|
||||
TO_CHAR(scheduled_date, 'YYYY-MM-DD') as date,
|
||||
avg_power,
|
||||
max_power,
|
||||
duration,
|
||||
title
|
||||
`).
|
||||
Where("user_id = ? AND status = ? AND avg_power > 0 AND scheduled_date >= ?",
|
||||
userID, "completed", cutoff).
|
||||
Order("scheduled_date ASC").
|
||||
Scan(&results).Error
|
||||
|
||||
if results == nil {
|
||||
results = []PowerPoint{}
|
||||
}
|
||||
|
||||
return results, err
|
||||
}
|
||||
|
||||
// PersonalBest holds a single personal best record.
|
||||
type PersonalBest struct {
|
||||
Category string `json:"category"`
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"rideaware/pkg/database"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
}
|
||||
@@ -31,3 +35,37 @@ func (s *Service) GetMonthlyStats(userID uint, months int) ([]PeriodStats, error
|
||||
func (s *Service) GetPersonalBests(userID uint) ([]PersonalBest, error) {
|
||||
return s.repo.GetPersonalBests(userID)
|
||||
}
|
||||
|
||||
// getUserFTP retrieves the user's FTP from the user_profiles table.
|
||||
func (s *Service) getUserFTP(userID uint) int {
|
||||
var ftp int
|
||||
database.DB.Table("user_profiles").
|
||||
Where("user_id = ?", userID).
|
||||
Select("ftp").
|
||||
Scan(&ftp)
|
||||
return ftp
|
||||
}
|
||||
|
||||
// GetTrainingLoad returns daily TSS data and the user's FTP.
|
||||
func (s *Service) GetTrainingLoad(userID uint, days int) ([]DailyTSS, int, error) {
|
||||
if days <= 0 || days > 730 {
|
||||
days = 180
|
||||
}
|
||||
|
||||
ftp := s.getUserFTP(userID)
|
||||
|
||||
data, err := s.repo.GetDailyTSS(userID, ftp, days)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return data, ftp, nil
|
||||
}
|
||||
|
||||
// GetPowerHistory returns power data points for completed workouts.
|
||||
func (s *Service) GetPowerHistory(userID uint, days int) ([]PowerPoint, error) {
|
||||
if days <= 0 || days > 730 {
|
||||
days = 365
|
||||
}
|
||||
return s.repo.GetPowerHistory(userID, days)
|
||||
}
|
||||
|
||||
@@ -35,8 +35,6 @@ func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("DEBUG GetProfile: User ID=%d, Profile=%+v", user.ID, user.Profile)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(GetProfileResponse{
|
||||
User: user,
|
||||
@@ -55,6 +53,14 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
MaxHR int `json:"max_hr"`
|
||||
RestingHR int `json:"resting_hr"`
|
||||
Weight float64 `json:"weight"`
|
||||
Height float64 `json:"height"`
|
||||
Age int `json:"age"`
|
||||
Gender string `json:"gender"`
|
||||
NutritionGoal string `json:"nutrition_goal"`
|
||||
TargetWeight float64 `json:"target_weight"`
|
||||
ActivityLevel string `json:"activity_level"`
|
||||
DietaryPref string `json:"dietary_preference"`
|
||||
Units string `json:"units"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -72,8 +78,6 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("DEBUG UpdateProfile: Before - Profile=%+v", user.Profile)
|
||||
|
||||
if user.Profile != nil {
|
||||
user.Profile.FirstName = req.FirstName
|
||||
user.Profile.LastName = req.LastName
|
||||
@@ -82,11 +86,19 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
user.Profile.MaxHR = req.MaxHR
|
||||
user.Profile.RestingHR = req.RestingHR
|
||||
user.Profile.Weight = req.Weight
|
||||
|
||||
log.Printf("DEBUG UpdateProfile: After - Profile=%+v", user.Profile)
|
||||
user.Profile.Height = req.Height
|
||||
user.Profile.Age = req.Age
|
||||
user.Profile.Gender = req.Gender
|
||||
user.Profile.NutritionGoal = req.NutritionGoal
|
||||
user.Profile.TargetWeight = req.TargetWeight
|
||||
user.Profile.ActivityLevel = req.ActivityLevel
|
||||
user.Profile.DietaryPref = req.DietaryPref
|
||||
if req.Units == "imperial" || req.Units == "metric" {
|
||||
user.Profile.Units = req.Units
|
||||
}
|
||||
|
||||
if err := h.service.UpdateUser(user); err != nil {
|
||||
log.Printf("DEBUG UpdateProfile: Error updating - %v", err)
|
||||
log.Printf("UpdateProfile: error saving - %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "failed to update profile"})
|
||||
@@ -95,7 +107,7 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
user, err = h.service.GetUserByID(claims.UserID)
|
||||
if err != nil {
|
||||
log.Printf("DEBUG UpdateProfile: Error reloading - %v", err)
|
||||
log.Printf("UpdateProfile: error reloading user - %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "failed to load profile"})
|
||||
@@ -103,8 +115,6 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("DEBUG UpdateProfile: Final - Profile=%+v", user.Profile)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(GetProfileResponse{
|
||||
User: user,
|
||||
|
||||
@@ -13,6 +13,7 @@ type User struct {
|
||||
Username string `gorm:"uniqueIndex;not null" json:"username"`
|
||||
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
||||
Password string `gorm:"not null" json:"-"`
|
||||
Role string `gorm:"default:'athlete'" json:"role"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -33,6 +34,14 @@ type Profile struct {
|
||||
MaxHR int `gorm:"default:0" json:"max_hr"`
|
||||
FTP int `gorm:"default:0" json:"ftp"`
|
||||
Weight float64 `gorm:"default:0" json:"weight"`
|
||||
Height float64 `gorm:"default:0" json:"height"` // cm
|
||||
Age int `gorm:"default:0" json:"age"`
|
||||
Gender string `gorm:"default:''" json:"gender"` // male, female
|
||||
NutritionGoal string `gorm:"default:''" json:"nutrition_goal"` // weight_loss, maintenance, performance
|
||||
TargetWeight float64 `gorm:"default:0" json:"target_weight"` // kg
|
||||
ActivityLevel string `gorm:"default:''" json:"activity_level"` // sedentary, lightly_active, active, very_active
|
||||
DietaryPref string `gorm:"default:''" json:"dietary_preference"` // balanced, high_carb, high_protein, keto
|
||||
Units string `gorm:"default:'metric'" json:"units"` // metric, imperial
|
||||
TotalRides int `gorm:"default:0" json:"total_rides"`
|
||||
TotalDistance float64 `gorm:"default:0" json:"total_distance"`
|
||||
TotalTime int `gorm:"default:0" json:"total_time"`
|
||||
|
||||
@@ -61,8 +61,6 @@ func (r *Repository) GetUserByID(id uint) (*User, error) {
|
||||
user.Profile = &profile
|
||||
}
|
||||
|
||||
log.Printf("DEBUG: Loaded user %d, profile ID=%d, profile=%+v", id, profile.ID, user.Profile)
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -27,15 +27,18 @@ func NewService() *Service {
|
||||
}
|
||||
|
||||
func (s *Service) CreateUser(username, password, email, firstName, lastName string) (*User, error) {
|
||||
if username == "" || password == "" {
|
||||
return nil, errors.New("username and password are required")
|
||||
if username == "" || password == "" || email == "" {
|
||||
return nil, errors.New("username, password, and email are required")
|
||||
}
|
||||
|
||||
// Username: 3-30 chars, alphanumeric + underscores/hyphens, must start with a letter
|
||||
if !isValidUsername(username) {
|
||||
return nil, errors.New("username must be 3-30 characters, start with a letter, and contain only letters, numbers, underscores, or hyphens")
|
||||
}
|
||||
|
||||
if email != "" {
|
||||
if !isValidEmail(email) {
|
||||
return nil, errors.New("invalid email format")
|
||||
}
|
||||
}
|
||||
|
||||
exists, err := s.repo.UserExists(username, email)
|
||||
if err != nil {
|
||||
@@ -158,6 +161,14 @@ func (s *Service) UpdateUser(user *User) error {
|
||||
return s.repo.UpdateUser(user)
|
||||
}
|
||||
|
||||
func isValidUsername(username string) bool {
|
||||
if len(username) < 3 || len(username) > 30 {
|
||||
return false
|
||||
}
|
||||
regex := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]{2,29}$`)
|
||||
return regex.MatchString(username)
|
||||
}
|
||||
|
||||
func isValidEmail(email string) bool {
|
||||
regex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||
return regex.MatchString(email)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rideaware/internal/config"
|
||||
@@ -43,6 +44,7 @@ func (h *Handler) CreateWorkout(w http.ResponseWriter, r *http.Request) {
|
||||
ScheduledDate string `json:"scheduled_date"`
|
||||
Duration int `json:"duration"`
|
||||
Notes string `json:"notes"`
|
||||
Tags Tags `json:"tags"`
|
||||
WorkoutData *WorkoutDataJSON `json:"workout_data"`
|
||||
FileType string `json:"file_type"`
|
||||
EquipmentID *uint `json:"equipment_id"`
|
||||
@@ -105,6 +107,7 @@ func (h *Handler) CreateWorkout(w http.ResponseWriter, r *http.Request) {
|
||||
ScheduledDate: scheduledDate,
|
||||
Duration: req.Duration,
|
||||
Notes: req.Notes,
|
||||
Tags: req.Tags,
|
||||
FileType: req.FileType,
|
||||
WorkoutData: *workoutData,
|
||||
EquipmentID: req.EquipmentID,
|
||||
@@ -127,11 +130,25 @@ func (h *Handler) CreateWorkout(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(workout)
|
||||
}
|
||||
|
||||
// GetWorkouts GET /api/protected/workouts
|
||||
// GetWorkouts GET /api/protected/workouts?tags=tag1,tag2
|
||||
func (h *Handler) GetWorkouts(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
|
||||
workouts, err := h.service.GetUserWorkouts(claims.UserID)
|
||||
tagsParam := r.URL.Query().Get("tags")
|
||||
var workouts []Workout
|
||||
var err error
|
||||
|
||||
if tagsParam != "" {
|
||||
tags := strings.Split(tagsParam, ",")
|
||||
// Trim whitespace from each tag
|
||||
for i := range tags {
|
||||
tags[i] = strings.TrimSpace(tags[i])
|
||||
}
|
||||
workouts, err = h.service.GetUserWorkoutsByTags(claims.UserID, tags)
|
||||
} else {
|
||||
workouts, err = h.service.GetUserWorkouts(claims.UserID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
@@ -212,8 +229,10 @@ func (h *Handler) UpdateWorkout(w http.ResponseWriter, r *http.Request) {
|
||||
MaxPower int `json:"max_power"`
|
||||
MaxHR int `json:"max_hr"`
|
||||
CaloriesBurned int `json:"calories_burned"`
|
||||
RPE int `json:"rpe"`
|
||||
Notes string `json:"notes"`
|
||||
EquipmentID *uint `json:"equipment_id"`
|
||||
Tags Tags `json:"tags"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -267,12 +286,18 @@ func (h *Handler) UpdateWorkout(w http.ResponseWriter, r *http.Request) {
|
||||
if req.CaloriesBurned > 0 {
|
||||
workout.CaloriesBurned = req.CaloriesBurned
|
||||
}
|
||||
if req.RPE > 0 && req.RPE <= 10 {
|
||||
workout.RPE = req.RPE
|
||||
}
|
||||
if req.Notes != "" {
|
||||
workout.Notes = req.Notes
|
||||
}
|
||||
if req.EquipmentID != nil {
|
||||
workout.EquipmentID = req.EquipmentID
|
||||
}
|
||||
if req.Tags != nil {
|
||||
workout.Tags = req.Tags
|
||||
}
|
||||
|
||||
if err := h.service.repo.UpdateWorkout(workout); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -309,6 +334,74 @@ func (h *Handler) DeleteWorkout(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// RemoveDuplicates POST /api/protected/workouts/remove-duplicates
|
||||
func (h *Handler) RemoveDuplicates(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
|
||||
removed, err := h.service.RemoveDuplicates(claims.UserID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "failed to remove duplicates"})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]int64{"removed": removed})
|
||||
}
|
||||
|
||||
// RescheduleWorkout PUT /api/protected/workouts/reschedule
|
||||
func (h *Handler) RescheduleWorkout(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
|
||||
idStr := r.URL.Query().Get("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "invalid workout id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ScheduledDate string `json:"scheduled_date"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
newDate, err := time.Parse("2006-01-02", req.ScheduledDate)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "invalid date format, use YYYY-MM-DD"})
|
||||
return
|
||||
}
|
||||
|
||||
workout, err := h.service.repo.GetWorkoutByID(uint(id), claims.UserID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "workout not found"})
|
||||
return
|
||||
}
|
||||
|
||||
workout.ScheduledDate = newDate
|
||||
if err := h.service.repo.UpdateWorkout(workout); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "failed to reschedule workout"})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(workout)
|
||||
}
|
||||
|
||||
// GetWorkoutTypes GET /api/protected/workout-types
|
||||
func (h *Handler) GetWorkoutTypes(w http.ResponseWriter, r *http.Request) {
|
||||
types := []map[string]interface{}{
|
||||
|
||||
@@ -3,6 +3,8 @@ package workout
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -26,11 +28,46 @@ type Workout struct {
|
||||
EquipmentID *uint `gorm:"index" json:"equipment_id"`
|
||||
FileURL string `gorm:"default:''" json:"file_url"`
|
||||
WorkoutData WorkoutDataJSON `gorm:"type:jsonb" json:"workout_data,omitempty"`
|
||||
RPE int `gorm:"default:0" json:"rpe"`
|
||||
Notes string `json:"notes"`
|
||||
Tags Tags `gorm:"type:jsonb;default:'[]'" json:"tags"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Tags is a JSONB array of string tags for workouts
|
||||
type Tags []string
|
||||
|
||||
// Scan implements the sql.Scanner interface for reading from the database
|
||||
func (t *Tags) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*t = Tags{}
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
return json.Unmarshal(v, t)
|
||||
case string:
|
||||
return json.Unmarshal([]byte(v), t)
|
||||
default:
|
||||
return fmt.Errorf("unsupported type for Tags: %T", value)
|
||||
}
|
||||
}
|
||||
|
||||
// Value implements the driver.Valuer interface for writing to the database
|
||||
func (t Tags) Value() (driver.Value, error) {
|
||||
if t == nil {
|
||||
return json.Marshal([]string{})
|
||||
}
|
||||
return json.Marshal(t)
|
||||
}
|
||||
|
||||
// String returns a comma-separated string representation
|
||||
func (t Tags) String() string {
|
||||
return strings.Join(t, ", ")
|
||||
}
|
||||
|
||||
type WorkoutDataJSON struct {
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author"`
|
||||
|
||||
@@ -39,6 +39,17 @@ func (r *Repository) GetUserWorkouts(userID uint) ([]Workout, error) {
|
||||
return workouts, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetUserWorkoutsByTags(userID uint, tags []string) ([]Workout, error) {
|
||||
var workouts []Workout
|
||||
// Use PostgreSQL jsonb ?| operator to check if the tags array contains any of the given tags
|
||||
if err := database.DB.Where("user_id = ? AND tags ?| ?", userID, tags).
|
||||
Order("scheduled_date DESC").
|
||||
Find(&workouts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return workouts, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetWorkoutsByDateRange(userID uint, start, end time.Time) ([]Workout, error) {
|
||||
var workouts []Workout
|
||||
if err := database.DB.Where("user_id = ? AND scheduled_date BETWEEN ? AND ?", userID, start, end).
|
||||
@@ -64,6 +75,33 @@ func (r *Repository) DeleteWorkout(id, userID uint) error {
|
||||
Delete(&Workout{}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) RemoveDuplicates(userID uint) (int64, error) {
|
||||
// Find IDs to keep: the minimum ID for each (title, scheduled_date, duration) group
|
||||
// Delete all other workouts that are duplicates
|
||||
result := database.DB.Exec(`
|
||||
DELETE FROM workouts
|
||||
WHERE user_id = ? AND id NOT IN (
|
||||
SELECT MIN(id)
|
||||
FROM workouts
|
||||
WHERE user_id = ?
|
||||
GROUP BY title, scheduled_date, duration
|
||||
)
|
||||
`, userID, userID)
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
return result.RowsAffected, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetCompletedWorkoutOnDate(userID uint, date string) (*Workout, error) {
|
||||
var w Workout
|
||||
if err := database.DB.Where("user_id = ? AND status = 'completed' AND DATE(scheduled_date) = ?", userID, date).
|
||||
First(&w).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
type EquipmentStat struct {
|
||||
EquipmentID uint `json:"equipment_id"`
|
||||
TotalRides int `json:"total_rides"`
|
||||
|
||||
@@ -39,6 +39,13 @@ func (s *Service) GetUserWorkouts(userID uint) ([]Workout, error) {
|
||||
return s.repo.GetUserWorkouts(userID)
|
||||
}
|
||||
|
||||
func (s *Service) GetUserWorkoutsByTags(userID uint, tags []string) ([]Workout, error) {
|
||||
if len(tags) == 0 {
|
||||
return s.repo.GetUserWorkouts(userID)
|
||||
}
|
||||
return s.repo.GetUserWorkoutsByTags(userID, tags)
|
||||
}
|
||||
|
||||
func (s *Service) GetWorkoutsByMonth(userID uint, year, month int) ([]Workout, error) {
|
||||
return s.repo.GetWorkoutsByMonth(userID, year, month)
|
||||
}
|
||||
@@ -87,6 +94,10 @@ func (s *Service) DeleteWorkout(id, userID uint) error {
|
||||
return s.repo.DeleteWorkout(id, userID)
|
||||
}
|
||||
|
||||
func (s *Service) RemoveDuplicates(userID uint) (int64, error) {
|
||||
return s.repo.RemoveDuplicates(userID)
|
||||
}
|
||||
|
||||
func (s *Service) GetEquipmentStats(userID uint) ([]EquipmentStat, error) {
|
||||
return s.repo.GetEquipmentStats(userID)
|
||||
}
|
||||
Reference in New Issue
Block a user