Files
cmr-mini/backend/internal/service/session_service.go

325 lines
10 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 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)
if err != nil {
return nil, err
}
if session.Status == "finished" || session.Status == "cancelled" || session.Status == "failed" {
return nil, apperr.New(http.StatusConflict, "session_not_startable", "session cannot be started")
}
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); err != nil {
return nil, err
}
if locked.Status == "finished" || locked.Status == "cancelled" || locked.Status == "failed" {
return nil, apperr.New(http.StatusConflict, "session_not_startable", "session cannot be started")
}
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 err := tx.Commit(ctx); err != nil {
return nil, err
}
return buildSessionResult(updated), nil
}
func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionInput) (*SessionResult, error) {
input.Status = normalizeFinishStatus(input.Status)
session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken)
if err != nil {
return nil, err
}
if session.Status == "finished" || session.Status == "cancelled" || session.Status == "failed" {
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); err != nil {
return nil, err
}
if locked.Status == "finished" || locked.Status == "cancelled" || locked.Status == "failed" {
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) (*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); err != nil {
return nil, err
}
return session, nil
}
func (s *SessionService) verifySessionToken(session *postgres.Session, sessionToken string) error {
if session.SessionTokenExpiresAt.Before(time.Now().UTC()) {
return apperr.New(http.StatusUnauthorized, "session_token_expired", "session token expired")
}
if session.SessionTokenHash != security.HashText(sessionToken) {
return apperr.New(http.StatusUnauthorized, "invalid_session_token", "invalid session token")
}
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 {
switch strings.TrimSpace(value) {
case "failed":
return "failed"
case "cancelled":
return "cancelled"
default:
return "finished"
}
}
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)
}