936 lines
31 KiB
Go
936 lines
31 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"cmr-backend/internal/apperr"
|
|
"cmr-backend/internal/platform/security"
|
|
"cmr-backend/internal/store/postgres"
|
|
)
|
|
|
|
type AdminProductionService struct {
|
|
store *postgres.Store
|
|
}
|
|
|
|
type AdminPlaceSummary struct {
|
|
ID string `json:"id"`
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
Region *string `json:"region,omitempty"`
|
|
CoverURL *string `json:"coverUrl,omitempty"`
|
|
Description *string `json:"description,omitempty"`
|
|
CenterPoint map[string]any `json:"centerPoint,omitempty"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type AdminPlaceDetail struct {
|
|
Place AdminPlaceSummary `json:"place"`
|
|
MapAssets []AdminMapAssetSummary `json:"mapAssets"`
|
|
}
|
|
|
|
type CreateAdminPlaceInput struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
Region *string `json:"region,omitempty"`
|
|
CoverURL *string `json:"coverUrl,omitempty"`
|
|
Description *string `json:"description,omitempty"`
|
|
CenterPoint map[string]any `json:"centerPoint,omitempty"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type AdminMapAssetSummary struct {
|
|
ID string `json:"id"`
|
|
PlaceID string `json:"placeId"`
|
|
LegacyMapID *string `json:"legacyMapId,omitempty"`
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
MapType string `json:"mapType"`
|
|
CoverURL *string `json:"coverUrl,omitempty"`
|
|
Description *string `json:"description,omitempty"`
|
|
Status string `json:"status"`
|
|
CurrentTileRelease *AdminTileReleaseBrief `json:"currentTileRelease,omitempty"`
|
|
}
|
|
|
|
type AdminTileReleaseBrief struct {
|
|
ID string `json:"id"`
|
|
VersionCode string `json:"versionCode"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type AdminMapAssetDetail struct {
|
|
MapAsset AdminMapAssetSummary `json:"mapAsset"`
|
|
TileReleases []AdminTileReleaseView `json:"tileReleases"`
|
|
CourseSets []AdminCourseSetBrief `json:"courseSets"`
|
|
}
|
|
|
|
type CreateAdminMapAssetInput struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
MapType string `json:"mapType"`
|
|
LegacyMapID *string `json:"legacyMapId,omitempty"`
|
|
CoverURL *string `json:"coverUrl,omitempty"`
|
|
Description *string `json:"description,omitempty"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type AdminTileReleaseView struct {
|
|
ID string `json:"id"`
|
|
LegacyVersionID *string `json:"legacyVersionId,omitempty"`
|
|
VersionCode string `json:"versionCode"`
|
|
Status string `json:"status"`
|
|
TileBaseURL string `json:"tileBaseUrl"`
|
|
MetaURL string `json:"metaUrl"`
|
|
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
|
|
Metadata map[string]any `json:"metadata,omitempty"`
|
|
PublishedAt *time.Time `json:"publishedAt,omitempty"`
|
|
}
|
|
|
|
type CreateAdminTileReleaseInput struct {
|
|
LegacyVersionID *string `json:"legacyVersionId,omitempty"`
|
|
VersionCode string `json:"versionCode"`
|
|
Status string `json:"status"`
|
|
TileBaseURL string `json:"tileBaseUrl"`
|
|
MetaURL string `json:"metaUrl"`
|
|
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
|
|
Metadata map[string]any `json:"metadata,omitempty"`
|
|
SetAsCurrent bool `json:"setAsCurrent"`
|
|
}
|
|
|
|
type AdminCourseSourceSummary struct {
|
|
ID string `json:"id"`
|
|
LegacyVersionID *string `json:"legacyVersionId,omitempty"`
|
|
SourceType string `json:"sourceType"`
|
|
FileURL string `json:"fileUrl"`
|
|
Checksum *string `json:"checksum,omitempty"`
|
|
ParserVersion *string `json:"parserVersion,omitempty"`
|
|
ImportStatus string `json:"importStatus"`
|
|
Metadata map[string]any `json:"metadata,omitempty"`
|
|
ImportedAt time.Time `json:"importedAt"`
|
|
}
|
|
|
|
type CreateAdminCourseSourceInput struct {
|
|
LegacyPlayfieldID *string `json:"legacyPlayfieldId,omitempty"`
|
|
LegacyVersionID *string `json:"legacyVersionId,omitempty"`
|
|
SourceType string `json:"sourceType"`
|
|
FileURL string `json:"fileUrl"`
|
|
Checksum *string `json:"checksum,omitempty"`
|
|
ParserVersion *string `json:"parserVersion,omitempty"`
|
|
ImportStatus string `json:"importStatus"`
|
|
Metadata map[string]any `json:"metadata,omitempty"`
|
|
}
|
|
|
|
type AdminCourseSetBrief struct {
|
|
ID string `json:"id"`
|
|
Code string `json:"code"`
|
|
Mode string `json:"mode"`
|
|
Name string `json:"name"`
|
|
Description *string `json:"description,omitempty"`
|
|
Status string `json:"status"`
|
|
CurrentVariant *AdminCourseVariantBrief `json:"currentVariant,omitempty"`
|
|
}
|
|
|
|
type AdminCourseVariantBrief struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
RouteCode *string `json:"routeCode,omitempty"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type AdminCourseSetDetail struct {
|
|
CourseSet AdminCourseSetBrief `json:"courseSet"`
|
|
Variants []AdminCourseVariantView `json:"variants"`
|
|
}
|
|
|
|
type CreateAdminCourseSetInput struct {
|
|
Code string `json:"code"`
|
|
Mode string `json:"mode"`
|
|
Name string `json:"name"`
|
|
Description *string `json:"description,omitempty"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type AdminCourseVariantView struct {
|
|
ID string `json:"id"`
|
|
SourceID *string `json:"sourceId,omitempty"`
|
|
Name string `json:"name"`
|
|
RouteCode *string `json:"routeCode,omitempty"`
|
|
Mode string `json:"mode"`
|
|
ControlCount *int `json:"controlCount,omitempty"`
|
|
Difficulty *string `json:"difficulty,omitempty"`
|
|
Status string `json:"status"`
|
|
IsDefault bool `json:"isDefault"`
|
|
ConfigPatch map[string]any `json:"configPatch,omitempty"`
|
|
Metadata map[string]any `json:"metadata,omitempty"`
|
|
}
|
|
|
|
type CreateAdminCourseVariantInput struct {
|
|
SourceID *string `json:"sourceId,omitempty"`
|
|
Name string `json:"name"`
|
|
RouteCode *string `json:"routeCode,omitempty"`
|
|
Mode string `json:"mode"`
|
|
ControlCount *int `json:"controlCount,omitempty"`
|
|
Difficulty *string `json:"difficulty,omitempty"`
|
|
Status string `json:"status"`
|
|
IsDefault bool `json:"isDefault"`
|
|
ConfigPatch map[string]any `json:"configPatch,omitempty"`
|
|
Metadata map[string]any `json:"metadata,omitempty"`
|
|
}
|
|
|
|
type AdminRuntimeBindingSummary struct {
|
|
ID string `json:"id"`
|
|
EventID string `json:"eventId"`
|
|
PlaceID string `json:"placeId"`
|
|
MapAssetID string `json:"mapAssetId"`
|
|
TileReleaseID string `json:"tileReleaseId"`
|
|
CourseSetID string `json:"courseSetId"`
|
|
CourseVariantID string `json:"courseVariantId"`
|
|
Status string `json:"status"`
|
|
Notes *string `json:"notes,omitempty"`
|
|
}
|
|
|
|
type CreateAdminRuntimeBindingInput struct {
|
|
EventID string `json:"eventId"`
|
|
PlaceID string `json:"placeId"`
|
|
MapAssetID string `json:"mapAssetId"`
|
|
TileReleaseID string `json:"tileReleaseId"`
|
|
CourseSetID string `json:"courseSetId"`
|
|
CourseVariantID string `json:"courseVariantId"`
|
|
Status string `json:"status"`
|
|
Notes *string `json:"notes,omitempty"`
|
|
}
|
|
|
|
func NewAdminProductionService(store *postgres.Store) *AdminProductionService {
|
|
return &AdminProductionService{store: store}
|
|
}
|
|
|
|
func (s *AdminProductionService) ListPlaces(ctx context.Context, limit int) ([]AdminPlaceSummary, error) {
|
|
items, err := s.store.ListPlaces(ctx, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]AdminPlaceSummary, 0, len(items))
|
|
for _, item := range items {
|
|
result = append(result, buildAdminPlaceSummary(item))
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *AdminProductionService) CreatePlace(ctx context.Context, input CreateAdminPlaceInput) (*AdminPlaceSummary, error) {
|
|
input.Code = strings.TrimSpace(input.Code)
|
|
input.Name = strings.TrimSpace(input.Name)
|
|
if input.Code == "" || input.Name == "" {
|
|
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
|
|
}
|
|
publicID, err := security.GeneratePublicID("place")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tx, err := s.store.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
item, err := s.store.CreatePlace(ctx, tx, postgres.CreatePlaceParams{
|
|
PublicID: publicID,
|
|
Code: input.Code,
|
|
Name: input.Name,
|
|
Region: trimStringPtr(input.Region),
|
|
CoverURL: trimStringPtr(input.CoverURL),
|
|
Description: trimStringPtr(input.Description),
|
|
CenterPoint: input.CenterPoint,
|
|
Status: normalizeCatalogStatus(input.Status),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
result := buildAdminPlaceSummary(*item)
|
|
return &result, nil
|
|
}
|
|
|
|
func (s *AdminProductionService) GetPlaceDetail(ctx context.Context, placePublicID string) (*AdminPlaceDetail, error) {
|
|
place, err := s.store.GetPlaceByPublicID(ctx, strings.TrimSpace(placePublicID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if place == nil {
|
|
return nil, apperr.New(http.StatusNotFound, "place_not_found", "place not found")
|
|
}
|
|
mapAssets, err := s.store.ListMapAssetsByPlaceID(ctx, place.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := &AdminPlaceDetail{
|
|
Place: buildAdminPlaceSummary(*place),
|
|
MapAssets: make([]AdminMapAssetSummary, 0, len(mapAssets)),
|
|
}
|
|
for _, item := range mapAssets {
|
|
summary, err := s.buildAdminMapAssetSummary(ctx, item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result.MapAssets = append(result.MapAssets, summary)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *AdminProductionService) CreateMapAsset(ctx context.Context, placePublicID string, input CreateAdminMapAssetInput) (*AdminMapAssetSummary, error) {
|
|
place, err := s.store.GetPlaceByPublicID(ctx, strings.TrimSpace(placePublicID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if place == nil {
|
|
return nil, apperr.New(http.StatusNotFound, "place_not_found", "place not found")
|
|
}
|
|
input.Code = strings.TrimSpace(input.Code)
|
|
input.Name = strings.TrimSpace(input.Name)
|
|
mapType := strings.TrimSpace(input.MapType)
|
|
if mapType == "" {
|
|
mapType = "standard"
|
|
}
|
|
if input.Code == "" || input.Name == "" {
|
|
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
|
|
}
|
|
|
|
var legacyMapID *string
|
|
if input.LegacyMapID != nil && strings.TrimSpace(*input.LegacyMapID) != "" {
|
|
legacyMap, err := s.store.GetResourceMapByPublicID(ctx, strings.TrimSpace(*input.LegacyMapID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if legacyMap == nil {
|
|
return nil, apperr.New(http.StatusNotFound, "legacy_map_not_found", "legacy map not found")
|
|
}
|
|
legacyMapID = &legacyMap.ID
|
|
}
|
|
|
|
publicID, err := security.GeneratePublicID("mapasset")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tx, err := s.store.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
item, err := s.store.CreateMapAsset(ctx, tx, postgres.CreateMapAssetParams{
|
|
PublicID: publicID,
|
|
PlaceID: place.ID,
|
|
LegacyMapID: legacyMapID,
|
|
Code: input.Code,
|
|
Name: input.Name,
|
|
MapType: mapType,
|
|
CoverURL: trimStringPtr(input.CoverURL),
|
|
Description: trimStringPtr(input.Description),
|
|
Status: normalizeCatalogStatus(input.Status),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
result, err := s.buildAdminMapAssetSummary(ctx, *item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
func (s *AdminProductionService) GetMapAssetDetail(ctx context.Context, mapAssetPublicID string) (*AdminMapAssetDetail, error) {
|
|
item, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(mapAssetPublicID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if item == nil {
|
|
return nil, apperr.New(http.StatusNotFound, "map_asset_not_found", "map asset not found")
|
|
}
|
|
summary, err := s.buildAdminMapAssetSummary(ctx, *item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tileReleases, err := s.store.ListTileReleasesByMapAssetID(ctx, item.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
courseSets, err := s.store.ListCourseSetsByMapAssetID(ctx, item.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := &AdminMapAssetDetail{
|
|
MapAsset: summary,
|
|
TileReleases: make([]AdminTileReleaseView, 0, len(tileReleases)),
|
|
CourseSets: make([]AdminCourseSetBrief, 0, len(courseSets)),
|
|
}
|
|
for _, release := range tileReleases {
|
|
result.TileReleases = append(result.TileReleases, buildAdminTileReleaseView(release))
|
|
}
|
|
for _, courseSet := range courseSets {
|
|
brief, err := s.buildAdminCourseSetBrief(ctx, courseSet)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result.CourseSets = append(result.CourseSets, brief)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *AdminProductionService) CreateTileRelease(ctx context.Context, mapAssetPublicID string, input CreateAdminTileReleaseInput) (*AdminTileReleaseView, error) {
|
|
mapAsset, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(mapAssetPublicID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if mapAsset == nil {
|
|
return nil, apperr.New(http.StatusNotFound, "map_asset_not_found", "map asset not found")
|
|
}
|
|
input.VersionCode = strings.TrimSpace(input.VersionCode)
|
|
input.TileBaseURL = strings.TrimSpace(input.TileBaseURL)
|
|
input.MetaURL = strings.TrimSpace(input.MetaURL)
|
|
if input.VersionCode == "" || input.TileBaseURL == "" || input.MetaURL == "" {
|
|
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "versionCode, tileBaseUrl and metaUrl are required")
|
|
}
|
|
|
|
var legacyVersionID *string
|
|
if input.LegacyVersionID != nil && strings.TrimSpace(*input.LegacyVersionID) != "" {
|
|
if mapAsset.LegacyMapPublicID == nil || strings.TrimSpace(*mapAsset.LegacyMapPublicID) == "" {
|
|
return nil, apperr.New(http.StatusBadRequest, "legacy_map_missing", "map asset has no linked legacy map")
|
|
}
|
|
legacyVersion, err := s.store.GetResourceMapVersionByPublicID(ctx, *mapAsset.LegacyMapPublicID, strings.TrimSpace(*input.LegacyVersionID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if legacyVersion == nil {
|
|
return nil, apperr.New(http.StatusNotFound, "legacy_tile_version_not_found", "legacy map version not found")
|
|
}
|
|
legacyVersionID = &legacyVersion.ID
|
|
}
|
|
|
|
publicID, err := security.GeneratePublicID("tile")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tx, err := s.store.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
publishedAt := time.Now()
|
|
release, err := s.store.CreateTileRelease(ctx, tx, postgres.CreateTileReleaseParams{
|
|
PublicID: publicID,
|
|
MapAssetID: mapAsset.ID,
|
|
LegacyMapVersionID: legacyVersionID,
|
|
VersionCode: input.VersionCode,
|
|
Status: normalizeReleaseStatus(input.Status),
|
|
TileBaseURL: input.TileBaseURL,
|
|
MetaURL: input.MetaURL,
|
|
PublishedAssetRoot: trimStringPtr(input.PublishedAssetRoot),
|
|
MetadataJSON: input.Metadata,
|
|
PublishedAt: &publishedAt,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if input.SetAsCurrent {
|
|
if err := s.store.SetMapAssetCurrentTileRelease(ctx, tx, mapAsset.ID, release.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
view := buildAdminTileReleaseView(*release)
|
|
return &view, nil
|
|
}
|
|
|
|
func (s *AdminProductionService) ListCourseSources(ctx context.Context, limit int) ([]AdminCourseSourceSummary, error) {
|
|
items, err := s.store.ListCourseSources(ctx, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]AdminCourseSourceSummary, 0, len(items))
|
|
for _, item := range items {
|
|
result = append(result, buildAdminCourseSourceSummary(item))
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *AdminProductionService) CreateCourseSource(ctx context.Context, input CreateAdminCourseSourceInput) (*AdminCourseSourceSummary, error) {
|
|
sourceType := strings.TrimSpace(input.SourceType)
|
|
fileURL := strings.TrimSpace(input.FileURL)
|
|
if sourceType == "" || fileURL == "" {
|
|
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "sourceType and fileUrl are required")
|
|
}
|
|
|
|
var legacyPlayfieldVersionID *string
|
|
if input.LegacyPlayfieldID != nil && input.LegacyVersionID != nil && strings.TrimSpace(*input.LegacyPlayfieldID) != "" && strings.TrimSpace(*input.LegacyVersionID) != "" {
|
|
version, err := s.store.GetResourcePlayfieldVersionByPublicID(ctx, strings.TrimSpace(*input.LegacyPlayfieldID), strings.TrimSpace(*input.LegacyVersionID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if version == nil {
|
|
return nil, apperr.New(http.StatusNotFound, "legacy_playfield_version_not_found", "legacy playfield version not found")
|
|
}
|
|
legacyPlayfieldVersionID = &version.ID
|
|
}
|
|
|
|
publicID, err := security.GeneratePublicID("csrc")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tx, err := s.store.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
item, err := s.store.CreateCourseSource(ctx, tx, postgres.CreateCourseSourceParams{
|
|
PublicID: publicID,
|
|
LegacyPlayfieldVersionID: legacyPlayfieldVersionID,
|
|
SourceType: sourceType,
|
|
FileURL: fileURL,
|
|
Checksum: trimStringPtr(input.Checksum),
|
|
ParserVersion: trimStringPtr(input.ParserVersion),
|
|
ImportStatus: normalizeCourseSourceStatus(input.ImportStatus),
|
|
MetadataJSON: input.Metadata,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
result := buildAdminCourseSourceSummary(*item)
|
|
return &result, nil
|
|
}
|
|
|
|
func (s *AdminProductionService) GetCourseSource(ctx context.Context, sourcePublicID string) (*AdminCourseSourceSummary, error) {
|
|
item, err := s.store.GetCourseSourceByPublicID(ctx, strings.TrimSpace(sourcePublicID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if item == nil {
|
|
return nil, apperr.New(http.StatusNotFound, "course_source_not_found", "course source not found")
|
|
}
|
|
result := buildAdminCourseSourceSummary(*item)
|
|
return &result, nil
|
|
}
|
|
|
|
func (s *AdminProductionService) CreateCourseSet(ctx context.Context, mapAssetPublicID string, input CreateAdminCourseSetInput) (*AdminCourseSetBrief, error) {
|
|
mapAsset, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(mapAssetPublicID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if mapAsset == nil {
|
|
return nil, apperr.New(http.StatusNotFound, "map_asset_not_found", "map asset not found")
|
|
}
|
|
input.Code = strings.TrimSpace(input.Code)
|
|
input.Mode = strings.TrimSpace(input.Mode)
|
|
input.Name = strings.TrimSpace(input.Name)
|
|
if input.Code == "" || input.Mode == "" || input.Name == "" {
|
|
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code, mode and name are required")
|
|
}
|
|
publicID, err := security.GeneratePublicID("cset")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tx, err := s.store.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
item, err := s.store.CreateCourseSet(ctx, tx, postgres.CreateCourseSetParams{
|
|
PublicID: publicID,
|
|
PlaceID: mapAsset.PlaceID,
|
|
MapAssetID: mapAsset.ID,
|
|
Code: input.Code,
|
|
Mode: input.Mode,
|
|
Name: input.Name,
|
|
Description: trimStringPtr(input.Description),
|
|
Status: normalizeCatalogStatus(input.Status),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
brief, err := s.buildAdminCourseSetBrief(ctx, *item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &brief, nil
|
|
}
|
|
|
|
func (s *AdminProductionService) GetCourseSetDetail(ctx context.Context, courseSetPublicID string) (*AdminCourseSetDetail, error) {
|
|
item, err := s.store.GetCourseSetByPublicID(ctx, strings.TrimSpace(courseSetPublicID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if item == nil {
|
|
return nil, apperr.New(http.StatusNotFound, "course_set_not_found", "course set not found")
|
|
}
|
|
brief, err := s.buildAdminCourseSetBrief(ctx, *item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
variants, err := s.store.ListCourseVariantsByCourseSetID(ctx, item.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := &AdminCourseSetDetail{
|
|
CourseSet: brief,
|
|
Variants: make([]AdminCourseVariantView, 0, len(variants)),
|
|
}
|
|
for _, variant := range variants {
|
|
result.Variants = append(result.Variants, buildAdminCourseVariantView(variant))
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *AdminProductionService) CreateCourseVariant(ctx context.Context, courseSetPublicID string, input CreateAdminCourseVariantInput) (*AdminCourseVariantView, error) {
|
|
courseSet, err := s.store.GetCourseSetByPublicID(ctx, strings.TrimSpace(courseSetPublicID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if courseSet == nil {
|
|
return nil, apperr.New(http.StatusNotFound, "course_set_not_found", "course set not found")
|
|
}
|
|
input.Name = strings.TrimSpace(input.Name)
|
|
input.Mode = strings.TrimSpace(input.Mode)
|
|
if input.Name == "" || input.Mode == "" {
|
|
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "name and mode are required")
|
|
}
|
|
|
|
var sourceID *string
|
|
if input.SourceID != nil && strings.TrimSpace(*input.SourceID) != "" {
|
|
source, err := s.store.GetCourseSourceByPublicID(ctx, strings.TrimSpace(*input.SourceID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if source == nil {
|
|
return nil, apperr.New(http.StatusNotFound, "course_source_not_found", "course source not found")
|
|
}
|
|
sourceID = &source.ID
|
|
}
|
|
|
|
publicID, err := security.GeneratePublicID("cvar")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tx, err := s.store.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
item, err := s.store.CreateCourseVariant(ctx, tx, postgres.CreateCourseVariantParams{
|
|
PublicID: publicID,
|
|
CourseSetID: courseSet.ID,
|
|
SourceID: sourceID,
|
|
Name: input.Name,
|
|
RouteCode: trimStringPtr(input.RouteCode),
|
|
Mode: input.Mode,
|
|
ControlCount: input.ControlCount,
|
|
Difficulty: trimStringPtr(input.Difficulty),
|
|
Status: normalizeCatalogStatus(input.Status),
|
|
IsDefault: input.IsDefault,
|
|
ConfigPatch: input.ConfigPatch,
|
|
MetadataJSON: input.Metadata,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if input.IsDefault {
|
|
if err := s.store.SetCourseSetCurrentVariant(ctx, tx, courseSet.ID, item.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
view := buildAdminCourseVariantView(*item)
|
|
return &view, nil
|
|
}
|
|
|
|
func (s *AdminProductionService) ListRuntimeBindings(ctx context.Context, limit int) ([]AdminRuntimeBindingSummary, error) {
|
|
items, err := s.store.ListMapRuntimeBindings(ctx, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]AdminRuntimeBindingSummary, 0, len(items))
|
|
for _, item := range items {
|
|
result = append(result, buildAdminRuntimeBindingSummary(item))
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *AdminProductionService) CreateRuntimeBinding(ctx context.Context, input CreateAdminRuntimeBindingInput) (*AdminRuntimeBindingSummary, error) {
|
|
eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(input.EventID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if eventRecord == nil {
|
|
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
|
|
}
|
|
place, err := s.store.GetPlaceByPublicID(ctx, strings.TrimSpace(input.PlaceID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if place == nil {
|
|
return nil, apperr.New(http.StatusNotFound, "place_not_found", "place not found")
|
|
}
|
|
mapAsset, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(input.MapAssetID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if mapAsset == nil || mapAsset.PlaceID != place.ID {
|
|
return nil, apperr.New(http.StatusBadRequest, "map_asset_mismatch", "map asset does not belong to place")
|
|
}
|
|
tileRelease, err := s.store.GetTileReleaseByPublicID(ctx, strings.TrimSpace(input.TileReleaseID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tileRelease == nil || tileRelease.MapAssetID != mapAsset.ID {
|
|
return nil, apperr.New(http.StatusBadRequest, "tile_release_mismatch", "tile release does not belong to map asset")
|
|
}
|
|
courseSet, err := s.store.GetCourseSetByPublicID(ctx, strings.TrimSpace(input.CourseSetID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if courseSet == nil || courseSet.PlaceID != place.ID || courseSet.MapAssetID != mapAsset.ID {
|
|
return nil, apperr.New(http.StatusBadRequest, "course_set_mismatch", "course set does not match place/map asset")
|
|
}
|
|
courseVariant, err := s.store.GetCourseVariantByPublicID(ctx, strings.TrimSpace(input.CourseVariantID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if courseVariant == nil || courseVariant.CourseSetID != courseSet.ID {
|
|
return nil, apperr.New(http.StatusBadRequest, "course_variant_mismatch", "course variant does not belong to course set")
|
|
}
|
|
|
|
publicID, err := security.GeneratePublicID("rtbind")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tx, err := s.store.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
item, err := s.store.CreateMapRuntimeBinding(ctx, tx, postgres.CreateMapRuntimeBindingParams{
|
|
PublicID: publicID,
|
|
EventID: eventRecord.ID,
|
|
PlaceID: place.ID,
|
|
MapAssetID: mapAsset.ID,
|
|
TileReleaseID: tileRelease.ID,
|
|
CourseSetID: courseSet.ID,
|
|
CourseVariantID: courseVariant.ID,
|
|
Status: normalizeRuntimeBindingStatus(input.Status),
|
|
Notes: trimStringPtr(input.Notes),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
created, err := s.store.GetMapRuntimeBindingByPublicID(ctx, item.PublicID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if created == nil {
|
|
return nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
|
|
}
|
|
result := buildAdminRuntimeBindingSummary(*created)
|
|
return &result, nil
|
|
}
|
|
|
|
func (s *AdminProductionService) GetRuntimeBinding(ctx context.Context, runtimeBindingPublicID string) (*AdminRuntimeBindingSummary, error) {
|
|
item, err := s.store.GetMapRuntimeBindingByPublicID(ctx, strings.TrimSpace(runtimeBindingPublicID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if item == nil {
|
|
return nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
|
|
}
|
|
result := buildAdminRuntimeBindingSummary(*item)
|
|
return &result, nil
|
|
}
|
|
|
|
func (s *AdminProductionService) buildAdminMapAssetSummary(ctx context.Context, item postgres.MapAsset) (AdminMapAssetSummary, error) {
|
|
result := AdminMapAssetSummary{
|
|
ID: item.PublicID,
|
|
PlaceID: item.PlaceID,
|
|
LegacyMapID: item.LegacyMapPublicID,
|
|
Code: item.Code,
|
|
Name: item.Name,
|
|
MapType: item.MapType,
|
|
CoverURL: item.CoverURL,
|
|
Description: item.Description,
|
|
Status: item.Status,
|
|
}
|
|
if item.CurrentTileReleaseID != nil {
|
|
releases, err := s.store.ListTileReleasesByMapAssetID(ctx, item.ID)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
for _, release := range releases {
|
|
if release.ID == *item.CurrentTileReleaseID {
|
|
result.CurrentTileRelease = &AdminTileReleaseBrief{
|
|
ID: release.PublicID,
|
|
VersionCode: release.VersionCode,
|
|
Status: release.Status,
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *AdminProductionService) buildAdminCourseSetBrief(ctx context.Context, item postgres.CourseSet) (AdminCourseSetBrief, error) {
|
|
result := AdminCourseSetBrief{
|
|
ID: item.PublicID,
|
|
Code: item.Code,
|
|
Mode: item.Mode,
|
|
Name: item.Name,
|
|
Description: item.Description,
|
|
Status: item.Status,
|
|
}
|
|
if item.CurrentVariantID != nil {
|
|
variants, err := s.store.ListCourseVariantsByCourseSetID(ctx, item.ID)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
for _, variant := range variants {
|
|
if variant.ID == *item.CurrentVariantID {
|
|
result.CurrentVariant = &AdminCourseVariantBrief{
|
|
ID: variant.PublicID,
|
|
Name: variant.Name,
|
|
RouteCode: variant.RouteCode,
|
|
Status: variant.Status,
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func buildAdminPlaceSummary(item postgres.Place) AdminPlaceSummary {
|
|
return AdminPlaceSummary{
|
|
ID: item.PublicID,
|
|
Code: item.Code,
|
|
Name: item.Name,
|
|
Region: item.Region,
|
|
CoverURL: item.CoverURL,
|
|
Description: item.Description,
|
|
CenterPoint: decodeJSONMap(item.CenterPoint),
|
|
Status: item.Status,
|
|
}
|
|
}
|
|
|
|
func buildAdminTileReleaseView(item postgres.TileRelease) AdminTileReleaseView {
|
|
return AdminTileReleaseView{
|
|
ID: item.PublicID,
|
|
LegacyVersionID: item.LegacyMapVersionPub,
|
|
VersionCode: item.VersionCode,
|
|
Status: item.Status,
|
|
TileBaseURL: item.TileBaseURL,
|
|
MetaURL: item.MetaURL,
|
|
PublishedAssetRoot: item.PublishedAssetRoot,
|
|
Metadata: decodeJSONMap(item.MetadataJSON),
|
|
PublishedAt: item.PublishedAt,
|
|
}
|
|
}
|
|
|
|
func buildAdminCourseSourceSummary(item postgres.CourseSource) AdminCourseSourceSummary {
|
|
return AdminCourseSourceSummary{
|
|
ID: item.PublicID,
|
|
LegacyVersionID: item.LegacyPlayfieldVersionPub,
|
|
SourceType: item.SourceType,
|
|
FileURL: item.FileURL,
|
|
Checksum: item.Checksum,
|
|
ParserVersion: item.ParserVersion,
|
|
ImportStatus: item.ImportStatus,
|
|
Metadata: decodeJSONMap(item.MetadataJSON),
|
|
ImportedAt: item.ImportedAt,
|
|
}
|
|
}
|
|
|
|
func buildAdminCourseVariantView(item postgres.CourseVariant) AdminCourseVariantView {
|
|
return AdminCourseVariantView{
|
|
ID: item.PublicID,
|
|
SourceID: item.SourcePublicID,
|
|
Name: item.Name,
|
|
RouteCode: item.RouteCode,
|
|
Mode: item.Mode,
|
|
ControlCount: item.ControlCount,
|
|
Difficulty: item.Difficulty,
|
|
Status: item.Status,
|
|
IsDefault: item.IsDefault,
|
|
ConfigPatch: decodeJSONMap(item.ConfigPatch),
|
|
Metadata: decodeJSONMap(item.MetadataJSON),
|
|
}
|
|
}
|
|
|
|
func buildAdminRuntimeBindingSummary(item postgres.MapRuntimeBinding) AdminRuntimeBindingSummary {
|
|
return AdminRuntimeBindingSummary{
|
|
ID: item.PublicID,
|
|
EventID: item.EventPublicID,
|
|
PlaceID: item.PlacePublicID,
|
|
MapAssetID: item.MapAssetPublicID,
|
|
TileReleaseID: item.TileReleasePublicID,
|
|
CourseSetID: item.CourseSetPublicID,
|
|
CourseVariantID: item.CourseVariantPublicID,
|
|
Status: item.Status,
|
|
Notes: item.Notes,
|
|
}
|
|
}
|
|
|
|
func normalizeCourseSourceStatus(value string) string {
|
|
switch strings.TrimSpace(value) {
|
|
case "draft":
|
|
return "draft"
|
|
case "parsed":
|
|
return "parsed"
|
|
case "failed":
|
|
return "failed"
|
|
case "archived":
|
|
return "archived"
|
|
default:
|
|
return "imported"
|
|
}
|
|
}
|
|
|
|
func normalizeRuntimeBindingStatus(value string) string {
|
|
switch strings.TrimSpace(value) {
|
|
case "active":
|
|
return "active"
|
|
case "disabled":
|
|
return "disabled"
|
|
case "archived":
|
|
return "archived"
|
|
default:
|
|
return "draft"
|
|
}
|
|
}
|
|
|
|
func normalizeReleaseStatus(value string) string {
|
|
switch strings.TrimSpace(value) {
|
|
case "active":
|
|
return "active"
|
|
case "published":
|
|
return "published"
|
|
case "retired":
|
|
return "retired"
|
|
case "archived":
|
|
return "archived"
|
|
default:
|
|
return "draft"
|
|
}
|
|
}
|