package service import ( "context" "encoding/json" "net/http" "strings" "time" "cmr-backend/internal/apperr" "cmr-backend/internal/platform/security" "cmr-backend/internal/store/postgres" ) type SessionService struct { store *postgres.Store } type sessionTokenPolicy struct { AllowExpired bool } type SessionResult struct { Session struct { ID string `json:"id"` Status string `json:"status"` ClientType string `json:"clientType"` DeviceKey string `json:"deviceKey"` RouteCode *string `json:"routeCode,omitempty"` SessionTokenExpiresAt string `json:"sessionTokenExpiresAt"` LaunchedAt string `json:"launchedAt"` StartedAt *string `json:"startedAt,omitempty"` EndedAt *string `json:"endedAt,omitempty"` } `json:"session"` Event struct { ID string `json:"id"` DisplayName string `json:"displayName"` } `json:"event"` ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"` } type SessionActionInput struct { SessionPublicID string SessionToken string `json:"sessionToken"` } type FinishSessionInput struct { SessionPublicID string SessionToken string `json:"sessionToken"` Status string `json:"status"` Summary *SessionSummaryInput `json:"summary,omitempty"` } type SessionSummaryInput struct { FinalDurationSec *int `json:"finalDurationSec,omitempty"` FinalScore *int `json:"finalScore,omitempty"` CompletedControls *int `json:"completedControls,omitempty"` TotalControls *int `json:"totalControls,omitempty"` DistanceMeters *float64 `json:"distanceMeters,omitempty"` AverageSpeedKmh *float64 `json:"averageSpeedKmh,omitempty"` MaxHeartRateBpm *int `json:"maxHeartRateBpm,omitempty"` } func NewSessionService(store *postgres.Store) *SessionService { return &SessionService{store: store} } func (s *SessionService) GetSession(ctx context.Context, sessionPublicID, userID string) (*SessionResult, error) { sessionPublicID = strings.TrimSpace(sessionPublicID) if sessionPublicID == "" { return nil, apperr.New(http.StatusBadRequest, "invalid_params", "session id is required") } session, err := s.store.GetSessionByPublicID(ctx, sessionPublicID) if err != nil { return nil, err } if session == nil { return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found") } if userID != "" && session.UserID != userID { return nil, apperr.New(http.StatusForbidden, "session_forbidden", "session does not belong to current user") } return buildSessionResult(session), nil } func (s *SessionService) ListMySessions(ctx context.Context, userID string, limit int) ([]SessionResult, error) { if userID == "" { return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required") } sessions, err := s.store.ListSessionsByUserID(ctx, userID, limit) if err != nil { return nil, err } results := make([]SessionResult, 0, len(sessions)) for i := range sessions { results = append(results, *buildSessionResult(&sessions[i])) } return results, nil } func (s *SessionService) StartSession(ctx context.Context, input SessionActionInput) (*SessionResult, error) { session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken, sessionTokenPolicy{}) if err != nil { return nil, err } if session.Status == SessionStatusRunning || isSessionTerminalStatus(session.Status) { return buildSessionResult(session), nil } tx, err := s.store.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) locked, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID) if err != nil { return nil, err } if locked == nil { return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found") } if err := s.verifySessionToken(locked, input.SessionToken, sessionTokenPolicy{}); err != nil { return nil, err } if locked.Status == SessionStatusRunning || isSessionTerminalStatus(locked.Status) { if err := tx.Commit(ctx); err != nil { return nil, err } return buildSessionResult(locked), nil } if locked.Status == SessionStatusLaunched { if err := s.store.StartSession(ctx, tx, locked.ID); err != nil { return nil, err } } updated, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID) if err != nil { return nil, err } if updated == nil { return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found") } if err := tx.Commit(ctx); err != nil { return nil, err } return buildSessionResult(updated), nil } func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionInput) (*SessionResult, error) { status, err := normalizeFinishStatus(input.Status) if err != nil { return nil, err } input.Status = status session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken, sessionTokenPolicy{ AllowExpired: input.Status == SessionStatusCancelled, }) if err != nil { return nil, err } if isSessionTerminalStatus(session.Status) { return buildSessionResult(session), nil } tx, err := s.store.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) locked, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID) if err != nil { return nil, err } if locked == nil { return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found") } if err := s.verifySessionToken(locked, input.SessionToken, sessionTokenPolicy{ AllowExpired: input.Status == SessionStatusCancelled || isSessionTerminalStatus(locked.Status), }); err != nil { return nil, err } if isSessionTerminalStatus(locked.Status) { if err := tx.Commit(ctx); err != nil { return nil, err } return buildSessionResult(locked), nil } if err := s.store.FinishSession(ctx, tx, postgres.FinishSessionParams{ SessionID: locked.ID, Status: input.Status, }); err != nil { return nil, err } updated, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID) if err != nil { return nil, err } if _, err := s.store.UpsertSessionResult(ctx, tx, postgres.UpsertSessionResultParams{ SessionID: updated.ID, ResultStatus: input.Status, Summary: buildSummaryMap(input.Summary), FinalDurationSec: resolveDurationSeconds(updated, input.Summary), FinalScore: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.FinalScore }), CompletedControls: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.CompletedControls }), TotalControls: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.TotalControls }), DistanceMeters: summaryFloat(input.Summary, func(v *SessionSummaryInput) *float64 { return v.DistanceMeters }), AverageSpeedKmh: summaryFloat(input.Summary, func(v *SessionSummaryInput) *float64 { return v.AverageSpeedKmh }), MaxHeartRateBpm: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.MaxHeartRateBpm }), }); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } return buildSessionResult(updated), nil } func (s *SessionService) validateSessionAction(ctx context.Context, sessionPublicID, sessionToken string, policy sessionTokenPolicy) (*postgres.Session, error) { sessionPublicID = strings.TrimSpace(sessionPublicID) sessionToken = strings.TrimSpace(sessionToken) if sessionPublicID == "" || sessionToken == "" { return nil, apperr.New(http.StatusBadRequest, "invalid_params", "session id and sessionToken are required") } session, err := s.store.GetSessionByPublicID(ctx, sessionPublicID) if err != nil { return nil, err } if session == nil { return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found") } if err := s.verifySessionToken(session, sessionToken, policy); err != nil { return nil, err } return session, nil } func (s *SessionService) verifySessionToken(session *postgres.Session, sessionToken string, policy sessionTokenPolicy) error { if session.SessionTokenHash != security.HashText(sessionToken) { return apperr.New(http.StatusUnauthorized, "invalid_session_token", "invalid session token") } if !policy.AllowExpired && session.SessionTokenExpiresAt.Before(time.Now().UTC()) { return apperr.New(http.StatusUnauthorized, "session_token_expired", "session token expired") } return nil } func buildSessionResult(session *postgres.Session) *SessionResult { result := &SessionResult{} result.Session.ID = session.SessionPublicID result.Session.Status = session.Status result.Session.ClientType = session.ClientType result.Session.DeviceKey = session.DeviceKey result.Session.RouteCode = session.RouteCode result.Session.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339) result.Session.LaunchedAt = session.LaunchedAt.Format(time.RFC3339) if session.StartedAt != nil { value := session.StartedAt.Format(time.RFC3339) result.Session.StartedAt = &value } if session.EndedAt != nil { value := session.EndedAt.Format(time.RFC3339) result.Session.EndedAt = &value } if session.EventPublicID != nil { result.Event.ID = *session.EventPublicID } if session.EventDisplayName != nil { result.Event.DisplayName = *session.EventDisplayName } result.ResolvedRelease = buildResolvedReleaseFromSession(session, LaunchSourceEventCurrentRelease) return result } func normalizeFinishStatus(value string) (string, error) { switch strings.TrimSpace(value) { case "", SessionStatusFinished: return SessionStatusFinished, nil case SessionStatusFailed: return SessionStatusFailed, nil case SessionStatusCancelled: return SessionStatusCancelled, nil default: return "", apperr.New(http.StatusBadRequest, "invalid_finish_status", "status must be finished, failed or cancelled") } } func buildSummaryMap(summary *SessionSummaryInput) map[string]any { if summary == nil { return map[string]any{} } raw, err := json.Marshal(summary) if err != nil { return map[string]any{} } result := map[string]any{} if err := json.Unmarshal(raw, &result); err != nil { return map[string]any{} } return result } func resolveDurationSeconds(session *postgres.Session, summary *SessionSummaryInput) *int { if summary != nil && summary.FinalDurationSec != nil { return summary.FinalDurationSec } if session.StartedAt != nil { endAt := time.Now().UTC() if session.EndedAt != nil { endAt = *session.EndedAt } seconds := int(endAt.Sub(*session.StartedAt).Seconds()) if seconds < 0 { seconds = 0 } return &seconds } return nil } func summaryInt(summary *SessionSummaryInput, getter func(*SessionSummaryInput) *int) *int { if summary == nil { return nil } return getter(summary) } func summaryFloat(summary *SessionSummaryInput, getter func(*SessionSummaryInput) *float64) *float64 { if summary == nil { return nil } return getter(summary) }