| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346 |
- 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)
- }
|