package service import ( "context" "net/http" "strings" "time" "cmr-backend/internal/apperr" "cmr-backend/internal/platform/security" "cmr-backend/internal/store/postgres" ) const ( GuestLaunchSource = "public-default-experience" GuestIdentityProvider = "guest_device" GuestIdentityType = "guest" ) type PublicExperienceService struct { store *postgres.Store mapService *MapExperienceService eventService *EventService } type PublicEventPlayInput struct { EventPublicID string } type PublicLaunchEventInput struct { EventPublicID string `json:"-"` ReleaseID string `json:"releaseId,omitempty"` VariantID string `json:"variantId,omitempty"` ClientType string `json:"clientType"` DeviceKey string `json:"deviceKey"` } func NewPublicExperienceService(store *postgres.Store, mapService *MapExperienceService, eventService *EventService) *PublicExperienceService { return &PublicExperienceService{ store: store, mapService: mapService, eventService: eventService, } } func (s *PublicExperienceService) ListMaps(ctx context.Context, input ListExperienceMapsInput) ([]ExperienceMapSummary, error) { return s.mapService.ListMaps(ctx, input) } func (s *PublicExperienceService) GetMapDetail(ctx context.Context, mapPublicID string) (*ExperienceMapDetail, error) { return s.mapService.GetMapDetail(ctx, mapPublicID) } func (s *PublicExperienceService) GetEventDetail(ctx context.Context, eventPublicID string) (*EventDetailResult, error) { event, err := s.store.GetEventByPublicID(ctx, strings.TrimSpace(eventPublicID)) if err != nil { return nil, err } if err := ensurePublicExperienceEvent(event); err != nil { return nil, err } return s.eventService.GetEventDetail(ctx, eventPublicID) } func (s *PublicExperienceService) GetEventPlay(ctx context.Context, input PublicEventPlayInput) (*EventPlayResult, error) { input.EventPublicID = strings.TrimSpace(input.EventPublicID) if input.EventPublicID == "" { return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required") } event, err := s.store.GetEventByPublicID(ctx, input.EventPublicID) if err != nil { return nil, err } if err := ensurePublicExperienceEvent(event); err != nil { return nil, err } result := &EventPlayResult{} result.Event.ID = event.PublicID result.Event.Slug = event.Slug result.Event.DisplayName = event.DisplayName result.Event.Summary = event.Summary result.Event.Status = event.Status variantPlan := resolveVariantPlan(event.ReleasePayloadJSON) result.Play.AssignmentMode = variantPlan.AssignmentMode if len(variantPlan.CourseVariants) > 0 { result.Play.CourseVariants = variantPlan.CourseVariants } if event.CurrentReleasePubID != nil && event.ConfigLabel != nil && event.ManifestURL != nil { result.Release = &struct { ID string `json:"id"` ConfigLabel string `json:"configLabel"` ManifestURL string `json:"manifestUrl"` ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"` RouteCode *string `json:"routeCode,omitempty"` }{ ID: *event.CurrentReleasePubID, ConfigLabel: *event.ConfigLabel, ManifestURL: *event.ManifestURL, ManifestChecksumSha256: event.ManifestChecksum, RouteCode: event.RouteCode, } } result.ResolvedRelease = buildResolvedReleaseFromEvent(event, GuestLaunchSource) result.Runtime = buildRuntimeSummaryFromEvent(event) if preview, err := buildPreviewFromPayload(event.ReleasePayloadJSON); err != nil { return nil, err } else { result.Preview = preview } result.CurrentPresentation = buildPresentationSummaryFromEvent(event) if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil { return nil, err } else if enrichedPresentation != nil { result.CurrentPresentation = enrichedPresentation } result.CurrentContentBundle = buildContentBundleSummaryFromEvent(event) if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil { return nil, err } else if enrichedBundle != nil { result.CurrentContentBundle = enrichedBundle } canLaunch, launchReason := evaluateEventLaunchReadiness(event) result.Play.CanLaunch = canLaunch if canLaunch { result.Play.LaunchSource = GuestLaunchSource result.Play.PrimaryAction = "start" result.Play.Reason = "guest can start default experience" return result, nil } result.Play.PrimaryAction = "unavailable" result.Play.Reason = launchReason return result, nil } func (s *PublicExperienceService) LaunchEvent(ctx context.Context, input PublicLaunchEventInput) (*LaunchEventResult, error) { input.EventPublicID = strings.TrimSpace(input.EventPublicID) input.ReleaseID = strings.TrimSpace(input.ReleaseID) input.VariantID = strings.TrimSpace(input.VariantID) input.DeviceKey = strings.TrimSpace(input.DeviceKey) if input.EventPublicID == "" || input.DeviceKey == "" { return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id and deviceKey are required") } if err := validateClientType(input.ClientType); err != nil { return nil, err } event, err := s.store.GetEventByPublicID(ctx, input.EventPublicID) if err != nil { return nil, err } if err := ensurePublicExperienceEvent(event); err != nil { return nil, err } if canLaunch, reason := evaluateEventLaunchReadiness(event); !canLaunch { return nil, launchReadinessError(reason) } if input.ReleaseID != "" && event.CurrentReleasePubID != nil && input.ReleaseID != *event.CurrentReleasePubID { return nil, apperr.New(http.StatusConflict, "release_not_launchable", "requested release is not the current published release") } variantPlan := resolveVariantPlan(event.ReleasePayloadJSON) variant, err := resolveLaunchVariant(variantPlan, input.VariantID) if err != nil { return nil, err } routeCode := event.RouteCode var assignmentMode *string var variantID *string var variantName *string if variant != nil { resultMode := variant.AssignmentMode assignmentMode = &resultMode variantID = &variant.ID variantName = &variant.Name if variant.RouteCode != nil { routeCode = variant.RouteCode } } tx, err := s.store.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) guestUser, err := s.findOrCreateGuestUser(ctx, tx, input.ClientType, input.DeviceKey) if err != nil { return nil, err } if err := s.store.TouchUserLogin(ctx, tx, guestUser.ID); err != nil { return nil, err } sessionPublicID, err := security.GeneratePublicID("sess") if err != nil { return nil, err } sessionToken, err := security.GenerateToken(32) if err != nil { return nil, err } sessionTokenExpiresAt := time.Now().UTC().Add(2 * time.Hour) session, err := s.store.CreateGameSession(ctx, tx, postgres.CreateGameSessionParams{ SessionPublicID: sessionPublicID, UserID: guestUser.ID, EventID: event.ID, EventReleaseID: *event.CurrentReleaseID, DeviceKey: input.DeviceKey, ClientType: input.ClientType, AssignmentMode: assignmentMode, VariantID: variantID, VariantName: variantName, RouteCode: routeCode, SessionTokenHash: security.HashText(sessionToken), SessionTokenExpiresAt: sessionTokenExpiresAt, }) if err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } result := &LaunchEventResult{} result.Event.ID = event.PublicID result.Event.DisplayName = event.DisplayName result.Launch.Source = GuestLaunchSource result.Launch.ResolvedRelease = buildResolvedReleaseFromEvent(event, GuestLaunchSource) result.Launch.Variant = variant result.Launch.Runtime = buildRuntimeSummaryFromEvent(event) result.Launch.Presentation = buildPresentationSummaryFromEvent(event) if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil { return nil, err } else if enrichedPresentation != nil { result.Launch.Presentation = enrichedPresentation } result.Launch.ContentBundle = buildContentBundleSummaryFromEvent(event) if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil { return nil, err } else if enrichedBundle != nil { result.Launch.ContentBundle = enrichedBundle } result.Launch.Config.ConfigURL = *event.ManifestURL result.Launch.Config.ConfigLabel = *event.ConfigLabel result.Launch.Config.ConfigChecksumSha256 = event.ManifestChecksum result.Launch.Config.ReleaseID = *event.CurrentReleasePubID result.Launch.Config.RouteCode = routeCode result.Launch.Business.Source = GuestLaunchSource result.Launch.Business.EventID = event.PublicID result.Launch.Business.SessionID = session.SessionPublicID result.Launch.Business.SessionToken = sessionToken result.Launch.Business.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339) result.Launch.Business.RouteCode = routeCode result.Launch.Business.IsGuest = true return result, nil } func (s *PublicExperienceService) findOrCreateGuestUser(ctx context.Context, tx postgres.Tx, clientType, deviceKey string) (*postgres.User, error) { providerSubject := clientType + ":" + deviceKey user, err := s.store.FindUserByProviderSubject(ctx, tx, GuestIdentityProvider, providerSubject) if err != nil { return nil, err } if user != nil { return user, nil } userPublicID, err := security.GeneratePublicID("usr") if err != nil { return nil, err } user, err = s.store.CreateUser(ctx, tx, postgres.CreateUserParams{ PublicID: userPublicID, Status: "active", }) if err != nil { return nil, err } if err := s.store.CreateIdentity(ctx, tx, postgres.CreateIdentityParams{ UserID: user.ID, IdentityType: GuestIdentityType, Provider: GuestIdentityProvider, ProviderSubj: providerSubject, ProfileJSON: "{}", }); err != nil { return nil, err } return user, nil } func ensurePublicExperienceEvent(event *postgres.Event) error { if event == nil { return apperr.New(http.StatusNotFound, "event_not_found", "event not found") } if !event.IsDefaultExperience { return apperr.New(http.StatusForbidden, "event_not_public", "event is not available in guest mode") } return nil }