353 lines
11 KiB
Go
353 lines
11 KiB
Go
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"`
|
|
AssignmentMode *string `json:"assignmentMode,omitempty"`
|
|
VariantID *string `json:"variantId,omitempty"`
|
|
VariantName *string `json:"variantName,omitempty"`
|
|
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.AssignmentMode = session.AssignmentMode
|
|
result.Session.VariantID = session.VariantID
|
|
result.Session.VariantName = session.VariantName
|
|
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)
|
|
}
|