diff --git a/backend/internal/services/project/content_setup.go b/backend/internal/services/project/content_setup.go index 434ea6cb..401aba85 100644 --- a/backend/internal/services/project/content_setup.go +++ b/backend/internal/services/project/content_setup.go @@ -2,7 +2,6 @@ package project import ( "github.com/google/uuid" - "gorm.io/datatypes" "github.com/kurodakayn/mpp-backend/internal/dto" "github.com/kurodakayn/mpp-backend/internal/models" @@ -50,11 +49,3 @@ func (s *Service) validateBrandProfileForProject(userID uuid.UUID, workspaceID u func contentTemplateDefaultPlatforms(template *models.ContentTemplate) ([]string, error) { return contentsetup.ContentTemplateDefaultPlatforms(template, NormalizeProjectPlatforms) } - -func contentTemplatePlatformConfig(template *models.ContentTemplate, platform string) map[string]any { - return contentsetup.ContentTemplatePlatformConfig(template, platform) -} - -func mergePublicationConfig(base datatypes.JSON, extra map[string]any) (datatypes.JSON, error) { - return contentsetup.MergePublicationConfig(base, extra) -} diff --git a/backend/internal/services/project/detail.go b/backend/internal/services/project/detail.go index c0af1531..0c59cb52 100644 --- a/backend/internal/services/project/detail.go +++ b/backend/internal/services/project/detail.go @@ -1,7 +1,6 @@ package project import ( - "encoding/json" "errors" "github.com/google/uuid" @@ -9,6 +8,7 @@ import ( "github.com/kurodakayn/mpp-backend/internal/dto" "github.com/kurodakayn/mpp-backend/internal/models" + projectpublication "github.com/kurodakayn/mpp-backend/internal/services/project/publication" ) func (s *Service) enrichProjectDetail(detail *dto.ProjectDetail, project models.Project, userID *uuid.UUID) error { @@ -17,7 +17,7 @@ func (s *Service) enrichProjectDetail(detail *dto.ProjectDetail, project models. } detail.PublicationDetails = make([]dto.PublicationDetail, 0, len(project.Publications)) for _, publication := range project.Publications { - detail.PublicationDetails = append(detail.PublicationDetails, publicationDetailFromModel(publication, true)) + detail.PublicationDetails = append(detail.PublicationDetails, projectpublication.DetailFromModel(publication, true)) } if userID == nil { if detail.PermissionSources == nil { @@ -110,39 +110,3 @@ func appendProjectPermissionSource(sources []dto.ProjectPermissionSource, source } return append(sources, source) } - -func publicationDetailFromModel(pub models.ProjectPlatformPublication, includeContent bool) dto.PublicationDetail { - var rawConfig map[string]any - _ = json.Unmarshal(pub.Config, &rawConfig) - safeConfig := filterConfig(rawConfig) - - var rawContent map[string]any - _ = json.Unmarshal(pub.AdaptedContent, &rawContent) - safeContent := rawContent - if !includeContent { - safeContent = summarizeAdaptedContent(rawContent) - } - if safeContent == nil { - safeContent = map[string]any{} - } - - return dto.PublicationDetail{ - ID: pub.ID, - Platform: pub.Platform, - Enabled: pub.Enabled, - Status: pub.Status, - DraftStatus: pub.DraftStatus, - ReviewStatus: pub.ReviewStatus, - SyncRequired: pub.SyncRequired, - ErrorMessage: pub.ErrorMessage, - Config: safeConfig, - AdaptedContent: safeContent, - PublishURL: pub.PublishURL, - RemoteID: pub.RemoteID, - RetryCount: pub.RetryCount, - LastAttemptAt: pub.LastAttemptAt, - PublishedAt: pub.PublishedAt, - CreatedAt: pub.CreatedAt, - UpdatedAt: pub.UpdatedAt, - } -} diff --git a/backend/internal/services/project/list.go b/backend/internal/services/project/list.go index 59ea1722..e8d93f4a 100644 --- a/backend/internal/services/project/list.go +++ b/backend/internal/services/project/list.go @@ -1,13 +1,14 @@ package project import ( - "strings" + "context" "github.com/google/uuid" "gorm.io/gorm" "github.com/kurodakayn/mpp-backend/internal/dto" "github.com/kurodakayn/mpp-backend/internal/models" + projectlisting "github.com/kurodakayn/mpp-backend/internal/services/project/listing" projectpresenter "github.com/kurodakayn/mpp-backend/internal/services/project/presenter" ) @@ -30,6 +31,94 @@ func (s *Service) ListCachedWorkspaceProjects(workspaceID uuid.UUID, actorUserID return s.getCachedWorkspaceProjectList(workspaceID, actorUserID, cursor, page, limit, status, platform) } +func (s *Service) projectListCache() *projectlisting.Cache { + if s == nil { + return nil + } + return projectlisting.New(projectlisting.Config{ + Client: s.cache, + TTL: s.cacheTTL, + Group: s.cacheGroup, + Guard: s.projectListGuard, + }) +} + +func (s *Service) getCachedDashboardProjectList(cursor string, page, limit int, status, filterUserID, platform string, scopeUserID *uuid.UUID) (*dto.PaginationResponse, error) { + params := projectlisting.Params{ + Cursor: cursor, + Page: page, + Limit: limit, + Status: status, + FilterUserID: filterUserID, + Platform: platform, + ScopeUserID: projectlisting.UUIDStringValue(scopeUserID), + } + return s.projectListCache().Get(s.requestContext(), params, func(ctx context.Context) (*dto.PaginationResponse, error) { + return s.WithContext(ctx).computeProjectList(cursor, page, limit, status, filterUserID, platform, scopeUserID) + }) +} + +func (s *Service) getCachedWorkspaceProjectList(workspaceID uuid.UUID, actorUserID uuid.UUID, cursor string, page, limit int, status, platform string) (*dto.PaginationResponse, error) { + params := projectlisting.Params{ + Cursor: cursor, + Page: page, + Limit: limit, + Status: status, + Platform: platform, + WorkspaceID: workspaceID.String(), + ActorUserID: actorUserID.String(), + } + return s.projectListCache().Get(s.requestContext(), params, func(ctx context.Context) (*dto.PaginationResponse, error) { + return s.WithContext(ctx).computeWorkspaceProjectList(workspaceID, actorUserID, cursor, page, limit, status, platform) + }) +} + +func (s *Service) canUseDashboardProjectListCache() bool { + if s == nil { + return false + } + return s.projectListCache().CanUse(s.requestContext()) +} + +func (s *Service) InvalidateDashboardProjectListCache(ctx context.Context) { + if s == nil { + return + } + if ctx != nil { + s = s.WithContext(ctx) + } + s.invalidateDashboardProjectListCache() +} + +func (s *Service) invalidateDashboardProjectListCache() { + if s == nil { + return + } + s.projectListCache().Invalidate(s.requestContext()) +} + +func (s *Service) invalidateDashboardCaches(includeStats bool) { + ctx := s.requestContext() + s.InvalidateDashboardProjectListCache(ctx) + if includeStats && s.statsCache != nil { + s.statsCache.InvalidateDashboardStatsCache(ctx) + } +} + +func (s *Service) invalidateDashboardScopedStatsCache() { + if s.statsCache == nil { + return + } + s.statsCache.InvalidateDashboardScopedStatsCache(s.requestContext()) +} + +func (s *Service) refreshProjectReadModel(projectID uuid.UUID) { + if s.readModels == nil || projectID == uuid.Nil { + return + } + s.readModels.RefreshProjectAsync(s.requestContext(), projectID) +} + func (s *Service) computeProjectList(cursor string, page, limit int, status, filterUserID, platform string, scopeUserID *uuid.UUID) (*dto.PaginationResponse, error) { if scopeUserID == nil && platform == "" && s.canUseReadModels() { if resp, ok, err := s.adminProjectListFromReadModel(cursor, page, limit, status, filterUserID); err != nil { @@ -120,8 +209,8 @@ func (s *Service) projectListReadDB(scopeUserID *uuid.UUID) *gorm.DB { func (s *Service) ListProjectPage(query *gorm.DB, cursor string, page, limit int, scopeUserID *uuid.UUID) (*dto.PaginationResponse, error) { var projects []models.Project - page, limit = normalizeProjectListPage(page, limit) - query, err := applyProjectListCursor(query, cursor) + page, limit = projectlisting.NormalizePage(page, limit) + query, err := projectlisting.ApplyCursor(query, cursor) if err != nil { return nil, err } @@ -166,17 +255,17 @@ func (s *Service) ListProjectPage(query *gorm.DB, cursor string, page, limit int nextCursor := "" if hasMore && len(projects) > 0 { - nextCursor = encodeProjectListCursor(projects[len(projects)-1]) + nextCursor = projectlisting.EncodeCursor(projects[len(projects)-1]) } - return projectPaginationResponse(items, cursor, page, limit, hasMore, nextCursor), nil + return projectlisting.PaginationResponse(items, cursor, page, limit, hasMore, nextCursor), nil } func (s *Service) ListProjectSummaryPage(query *gorm.DB, cursor string, page, limit int) (*dto.PaginationResponse, error) { var summaries []models.ProjectListSummary - page, limit = normalizeProjectListPage(page, limit) - query, err := applyProjectListCursorColumns(query, cursor, "project_list_summaries.created_at", "project_list_summaries.project_id") + page, limit = projectlisting.NormalizePage(page, limit) + query, err := projectlisting.ApplyCursorColumns(query, cursor, "project_list_summaries.created_at", "project_list_summaries.project_id") if err != nil { return nil, err } @@ -206,32 +295,8 @@ func (s *Service) ListProjectSummaryPage(query *gorm.DB, cursor string, page, li nextCursor := "" if hasMore && len(summaries) > 0 { last := summaries[len(summaries)-1] - nextCursor = encodeProjectListCursorValues(last.CreatedAt, last.ProjectID) - } - - return projectPaginationResponse(items, cursor, page, limit, hasMore, nextCursor), nil -} - -func projectPaginationResponse(items []dto.ProjectListItem, cursor string, page int, limit int, hasMore bool, nextCursor string) *dto.PaginationResponse { - total := int64((page-1)*limit + len(items)) - if hasMore { - total++ - } - totalPages := page - if len(items) == 0 && page == 1 { - totalPages = 0 - } else if hasMore { - totalPages = page + 1 + nextCursor = projectlisting.EncodeCursorValues(last.CreatedAt, last.ProjectID) } - return &dto.PaginationResponse{ - Items: items, - Page: page, - Limit: limit, - Total: total, - TotalPages: totalPages, - Cursor: strings.TrimSpace(cursor), - NextCursor: nextCursor, - HasMore: hasMore, - } + return projectlisting.PaginationResponse(items, cursor, page, limit, hasMore, nextCursor), nil } diff --git a/backend/internal/services/project/list_cache.go b/backend/internal/services/project/list_cache.go deleted file mode 100644 index 3eff82bb..00000000 --- a/backend/internal/services/project/list_cache.go +++ /dev/null @@ -1,98 +0,0 @@ -package project - -import ( - "context" - - "github.com/google/uuid" - - "github.com/kurodakayn/mpp-backend/internal/dto" - projectcache "github.com/kurodakayn/mpp-backend/internal/services/project/cache" -) - -func (s *Service) projectListCache() *projectcache.Cache { - if s == nil { - return nil - } - return projectcache.New(projectcache.Config{ - Client: s.cache, - TTL: s.cacheTTL, - Group: s.cacheGroup, - Guard: s.projectListGuard, - }) -} - -func (s *Service) getCachedDashboardProjectList(cursor string, page, limit int, status, filterUserID, platform string, scopeUserID *uuid.UUID) (*dto.PaginationResponse, error) { - params := projectcache.Params{ - Cursor: cursor, - Page: page, - Limit: limit, - Status: status, - FilterUserID: filterUserID, - Platform: platform, - ScopeUserID: projectcache.UUIDStringValue(scopeUserID), - } - return s.projectListCache().Get(s.requestContext(), params, func(ctx context.Context) (*dto.PaginationResponse, error) { - return s.WithContext(ctx).computeProjectList(cursor, page, limit, status, filterUserID, platform, scopeUserID) - }) -} - -func (s *Service) getCachedWorkspaceProjectList(workspaceID uuid.UUID, actorUserID uuid.UUID, cursor string, page, limit int, status, platform string) (*dto.PaginationResponse, error) { - params := projectcache.Params{ - Cursor: cursor, - Page: page, - Limit: limit, - Status: status, - Platform: platform, - WorkspaceID: workspaceID.String(), - ActorUserID: actorUserID.String(), - } - return s.projectListCache().Get(s.requestContext(), params, func(ctx context.Context) (*dto.PaginationResponse, error) { - return s.WithContext(ctx).computeWorkspaceProjectList(workspaceID, actorUserID, cursor, page, limit, status, platform) - }) -} - -func (s *Service) canUseDashboardProjectListCache() bool { - if s == nil { - return false - } - return s.projectListCache().CanUse(s.requestContext()) -} - -func (s *Service) InvalidateDashboardProjectListCache(ctx context.Context) { - if s == nil { - return - } - if ctx != nil { - s = s.WithContext(ctx) - } - s.invalidateDashboardProjectListCache() -} - -func (s *Service) invalidateDashboardProjectListCache() { - if s == nil { - return - } - s.projectListCache().Invalidate(s.requestContext()) -} - -func (s *Service) invalidateDashboardCaches(includeStats bool) { - ctx := s.requestContext() - s.InvalidateDashboardProjectListCache(ctx) - if includeStats && s.statsCache != nil { - s.statsCache.InvalidateDashboardStatsCache(ctx) - } -} - -func (s *Service) invalidateDashboardScopedStatsCache() { - if s.statsCache == nil { - return - } - s.statsCache.InvalidateDashboardScopedStatsCache(s.requestContext()) -} - -func (s *Service) refreshProjectReadModel(projectID uuid.UUID) { - if s.readModels == nil || projectID == uuid.Nil { - return - } - s.readModels.RefreshProjectAsync(s.requestContext(), projectID) -} diff --git a/backend/internal/services/project/list_cursor.go b/backend/internal/services/project/list_cursor.go deleted file mode 100644 index 63db5efb..00000000 --- a/backend/internal/services/project/list_cursor.go +++ /dev/null @@ -1,94 +0,0 @@ -package project - -import ( - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "github.com/google/uuid" - "gorm.io/gorm" - - "github.com/kurodakayn/mpp-backend/internal/models" -) - -const ( - defaultProjectListLimit = 10 - maxProjectListLimit = 100 -) - -var errEmptyProjectListCursor = errors.New("empty project list cursor") - -type projectListCursor struct { - CreatedAt time.Time `json:"created_at"` - ID uuid.UUID `json:"id"` -} - -func normalizeProjectListPage(page, limit int) (int, int) { - if page < 1 { - page = 1 - } - if limit < 1 { - limit = defaultProjectListLimit - } - if limit > maxProjectListLimit { - limit = maxProjectListLimit - } - return page, limit -} - -func applyProjectListCursor(query *gorm.DB, cursor string) (*gorm.DB, error) { - return applyProjectListCursorColumns(query, cursor, "projects.created_at", "projects.id") -} - -func applyProjectListCursorColumns(query *gorm.DB, cursor, createdAtColumn, idColumn string) (*gorm.DB, error) { - if strings.TrimSpace(cursor) == "" { - return query, nil - } - decoded, err := decodeProjectListCursor(cursor) - if err != nil { - return nil, err - } - return query.Where( - fmt.Sprintf("(%s < ? OR (%s = ? AND %s > ?))", createdAtColumn, createdAtColumn, idColumn), - decoded.CreatedAt, - decoded.CreatedAt, - decoded.ID, - ), nil -} - -func decodeProjectListCursor(cursor string) (*projectListCursor, error) { - cursor = strings.TrimSpace(cursor) - if cursor == "" { - return nil, errEmptyProjectListCursor - } - raw, err := base64.RawURLEncoding.DecodeString(cursor) - if err != nil { - return nil, fmt.Errorf("%w: invalid project list cursor", ErrInvalidProject) - } - var decoded projectListCursor - if err := json.Unmarshal(raw, &decoded); err != nil { - return nil, fmt.Errorf("%w: invalid project list cursor", ErrInvalidProject) - } - if decoded.ID == uuid.Nil || decoded.CreatedAt.IsZero() { - return nil, fmt.Errorf("%w: invalid project list cursor", ErrInvalidProject) - } - return &decoded, nil -} - -func encodeProjectListCursor(project models.Project) string { - return encodeProjectListCursorValues(project.CreatedAt, project.ID) -} - -func encodeProjectListCursorValues(createdAt time.Time, id uuid.UUID) string { - encoded, err := json.Marshal(projectListCursor{ - CreatedAt: createdAt, - ID: id, - }) - if err != nil { - return "" - } - return base64.RawURLEncoding.EncodeToString(encoded) -} diff --git a/backend/internal/services/project/cache/cache.go b/backend/internal/services/project/listing/cache.go similarity index 99% rename from backend/internal/services/project/cache/cache.go rename to backend/internal/services/project/listing/cache.go index f456a886..be70e0b9 100644 --- a/backend/internal/services/project/cache/cache.go +++ b/backend/internal/services/project/listing/cache.go @@ -1,4 +1,4 @@ -package cache +package listing import ( "context" diff --git a/backend/internal/services/project/cache/list_cache_keys_test.go b/backend/internal/services/project/listing/cache_test.go similarity index 97% rename from backend/internal/services/project/cache/list_cache_keys_test.go rename to backend/internal/services/project/listing/cache_test.go index a7314c40..17e2c342 100644 --- a/backend/internal/services/project/cache/list_cache_keys_test.go +++ b/backend/internal/services/project/listing/cache_test.go @@ -1,4 +1,4 @@ -package cache +package listing import ( "testing" diff --git a/backend/internal/services/project/listing/cursor.go b/backend/internal/services/project/listing/cursor.go new file mode 100644 index 00000000..acdf4b6a --- /dev/null +++ b/backend/internal/services/project/listing/cursor.go @@ -0,0 +1,120 @@ +package listing + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + + "github.com/kurodakayn/mpp-backend/internal/dto" + "github.com/kurodakayn/mpp-backend/internal/models" + "github.com/kurodakayn/mpp-backend/internal/services/project/projecterr" +) + +const ( + defaultProjectListLimit = 10 + maxProjectListLimit = 100 +) + +var errEmptyCursor = errors.New("empty project list cursor") + +type cursorPayload struct { + CreatedAt time.Time `json:"created_at"` + ID uuid.UUID `json:"id"` +} + +func NormalizePage(page, limit int) (int, int) { + if page < 1 { + page = 1 + } + if limit < 1 { + limit = defaultProjectListLimit + } + if limit > maxProjectListLimit { + limit = maxProjectListLimit + } + return page, limit +} + +func ApplyCursor(query *gorm.DB, cursor string) (*gorm.DB, error) { + return ApplyCursorColumns(query, cursor, "projects.created_at", "projects.id") +} + +func ApplyCursorColumns(query *gorm.DB, cursor, createdAtColumn, idColumn string) (*gorm.DB, error) { + if strings.TrimSpace(cursor) == "" { + return query, nil + } + decoded, err := decodeCursor(cursor) + if err != nil { + return nil, err + } + return query.Where( + fmt.Sprintf("(%s < ? OR (%s = ? AND %s > ?))", createdAtColumn, createdAtColumn, idColumn), + decoded.CreatedAt, + decoded.CreatedAt, + decoded.ID, + ), nil +} + +func EncodeCursor(project models.Project) string { + return EncodeCursorValues(project.CreatedAt, project.ID) +} + +func EncodeCursorValues(createdAt time.Time, id uuid.UUID) string { + encoded, err := json.Marshal(cursorPayload{ + CreatedAt: createdAt, + ID: id, + }) + if err != nil { + return "" + } + return base64.RawURLEncoding.EncodeToString(encoded) +} + +func PaginationResponse(items []dto.ProjectListItem, cursor string, page int, limit int, hasMore bool, nextCursor string) *dto.PaginationResponse { + total := int64((page-1)*limit + len(items)) + if hasMore { + total++ + } + totalPages := page + if len(items) == 0 && page == 1 { + totalPages = 0 + } else if hasMore { + totalPages = page + 1 + } + + return &dto.PaginationResponse{ + Items: items, + Page: page, + Limit: limit, + Total: total, + TotalPages: totalPages, + Cursor: strings.TrimSpace(cursor), + NextCursor: nextCursor, + HasMore: hasMore, + } +} + +func decodeCursor(cursor string) (*cursorPayload, error) { + cursor = strings.TrimSpace(cursor) + if cursor == "" { + return nil, errEmptyCursor + } + raw, err := base64.RawURLEncoding.DecodeString(cursor) + if err != nil { + return nil, fmt.Errorf("%w: invalid project list cursor", projecterr.ErrInvalidProject) + } + var decoded cursorPayload + if err := json.Unmarshal(raw, &decoded); err != nil { + return nil, fmt.Errorf("%w: invalid project list cursor", projecterr.ErrInvalidProject) + } + if decoded.ID == uuid.Nil || decoded.CreatedAt.IsZero() { + return nil, fmt.Errorf("%w: invalid project list cursor", projecterr.ErrInvalidProject) + } + return &decoded, nil +} diff --git a/backend/internal/services/project/list_cache_test.go b/backend/internal/services/project/listing/list_cache_integration_test.go similarity index 99% rename from backend/internal/services/project/list_cache_test.go rename to backend/internal/services/project/listing/list_cache_integration_test.go index 73ec4959..70cf65b5 100644 --- a/backend/internal/services/project/list_cache_test.go +++ b/backend/internal/services/project/listing/list_cache_integration_test.go @@ -1,4 +1,4 @@ -package project_test +package listing_test import ( "context" diff --git a/backend/internal/services/project/media_usage.go b/backend/internal/services/project/media_usage.go index 99079c93..afcf9151 100644 --- a/backend/internal/services/project/media_usage.go +++ b/backend/internal/services/project/media_usage.go @@ -1,52 +1,19 @@ package project import ( - "regexp" - "strings" - "github.com/google/uuid" "gorm.io/gorm" - "gorm.io/gorm/clause" "github.com/kurodakayn/mpp-backend/internal/models" + projectmediausage "github.com/kurodakayn/mpp-backend/internal/services/project/mediausage" ) -const mediaObjectRefPrefix = "mpp://media/" - -var mediaObjectRefPattern = regexp.MustCompile(`mpp://media/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})`) - func refreshProjectMediaUsages(tx *gorm.DB, project models.Project, publications []models.ProjectPlatformPublication) error { - workspaceID := projectWorkspaceID(project) - if err := tx.Where("project_id = ?", project.ID).Delete(&models.MediaAssetUsage{}).Error; err != nil { - return err - } - - sourceRefs := collectMediaAssetIDs(project.SourceContent) - if err := upsertMediaUsages(tx, workspaceID, &project.ID, nil, nil, "project", project.ID, models.MediaAssetUsageEditorImage, sourceRefs); err != nil { - return err - } - for _, publication := range publications { - refs := collectMediaAssetIDs(string(publication.Config), string(publication.AdaptedContent)) - if len(refs) == 0 { - continue - } - if err := upsertMediaUsages(tx, workspaceID, &project.ID, &publication.ID, nil, "publication", publication.ID, publication.Platform, refs); err != nil { - return err - } - } - return nil + return projectmediausage.RefreshProject(tx, project, publications) } func refreshContentTemplateMediaUsages(tx *gorm.DB, workspaceID uuid.UUID, template models.ContentTemplate) error { - if template.ID == uuid.Nil { - return ErrInvalidProject - } - if err := tx.Where("template_id = ?", template.ID).Delete(&models.MediaAssetUsage{}).Error; err != nil { - return err - } - - refs := collectMediaAssetIDs(template.SourceTemplate, string(template.PlatformConfig)) - return upsertMediaUsages(tx, workspaceID, nil, nil, &template.ID, "template", template.ID, models.MediaAssetUsageEditorImage, refs) + return projectmediausage.RefreshContentTemplate(tx, workspaceID, template) } func (s *Service) RefreshProjectMediaUsages(projectID uuid.UUID) error { @@ -65,51 +32,3 @@ func (s *Service) RefreshProjectMediaUsages(projectID uuid.UUID) error { return refreshProjectMediaUsages(tx, project, publications) }) } - -func upsertMediaUsages(tx *gorm.DB, workspaceID uuid.UUID, projectID *uuid.UUID, publicationID *uuid.UUID, templateID *uuid.UUID, resourceType string, resourceID uuid.UUID, usageKind string, assetIDs []uuid.UUID) error { - for _, assetID := range assetIDs { - usage := models.MediaAssetUsage{ - MediaAssetID: assetID, - WorkspaceID: workspaceID, - ProjectID: projectID, - PublicationID: publicationID, - TemplateID: templateID, - ResourceType: resourceType, - ResourceID: resourceID, - UsageKind: usageKind, - } - if err := tx.Clauses(clause.OnConflict{ - Columns: []clause.Column{ - {Name: "media_asset_id"}, - {Name: "resource_type"}, - {Name: "resource_id"}, - }, - DoNothing: true, - }).Create(&usage).Error; err != nil { - return err - } - } - return nil -} - -func collectMediaAssetIDs(values ...string) []uuid.UUID { - seen := map[uuid.UUID]struct{}{} - assetIDs := make([]uuid.UUID, 0) - for _, value := range values { - for _, match := range mediaObjectRefPattern.FindAllStringSubmatch(value, -1) { - if len(match) != 2 || !strings.HasPrefix(match[0], mediaObjectRefPrefix) { - continue - } - assetID, err := uuid.Parse(match[1]) - if err != nil { - continue - } - if _, ok := seen[assetID]; ok { - continue - } - seen[assetID] = struct{}{} - assetIDs = append(assetIDs, assetID) - } - } - return assetIDs -} diff --git a/backend/internal/services/project/mediausage/media_usage.go b/backend/internal/services/project/mediausage/media_usage.go new file mode 100644 index 00000000..a77da27e --- /dev/null +++ b/backend/internal/services/project/mediausage/media_usage.go @@ -0,0 +1,106 @@ +package mediausage + +import ( + "regexp" + "strings" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/kurodakayn/mpp-backend/internal/models" + "github.com/kurodakayn/mpp-backend/internal/services/project/projecterr" +) + +const objectRefPrefix = "mpp://media/" + +var objectRefPattern = regexp.MustCompile(`mpp://media/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})`) + +func RefreshProject(tx *gorm.DB, project models.Project, publications []models.ProjectPlatformPublication) error { + workspaceID := projectWorkspaceID(project) + if err := tx.Where("project_id = ?", project.ID).Delete(&models.MediaAssetUsage{}).Error; err != nil { + return err + } + + sourceRefs := collectAssetIDs(project.SourceContent) + if err := upsert(tx, workspaceID, &project.ID, nil, nil, "project", project.ID, models.MediaAssetUsageEditorImage, sourceRefs); err != nil { + return err + } + for _, publication := range publications { + refs := collectAssetIDs(string(publication.Config), string(publication.AdaptedContent)) + if len(refs) == 0 { + continue + } + if err := upsert(tx, workspaceID, &project.ID, &publication.ID, nil, "publication", publication.ID, publication.Platform, refs); err != nil { + return err + } + } + return nil +} + +func RefreshContentTemplate(tx *gorm.DB, workspaceID uuid.UUID, template models.ContentTemplate) error { + if template.ID == uuid.Nil { + return projecterr.ErrInvalidProject + } + if err := tx.Where("template_id = ?", template.ID).Delete(&models.MediaAssetUsage{}).Error; err != nil { + return err + } + + refs := collectAssetIDs(template.SourceTemplate, string(template.PlatformConfig)) + return upsert(tx, workspaceID, nil, nil, &template.ID, "template", template.ID, models.MediaAssetUsageEditorImage, refs) +} + +func upsert(tx *gorm.DB, workspaceID uuid.UUID, projectID *uuid.UUID, publicationID *uuid.UUID, templateID *uuid.UUID, resourceType string, resourceID uuid.UUID, usageKind string, assetIDs []uuid.UUID) error { + for _, assetID := range assetIDs { + usage := models.MediaAssetUsage{ + MediaAssetID: assetID, + WorkspaceID: workspaceID, + ProjectID: projectID, + PublicationID: publicationID, + TemplateID: templateID, + ResourceType: resourceType, + ResourceID: resourceID, + UsageKind: usageKind, + } + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "media_asset_id"}, + {Name: "resource_type"}, + {Name: "resource_id"}, + }, + DoNothing: true, + }).Create(&usage).Error; err != nil { + return err + } + } + return nil +} + +func collectAssetIDs(values ...string) []uuid.UUID { + seen := map[uuid.UUID]struct{}{} + assetIDs := make([]uuid.UUID, 0) + for _, value := range values { + for _, match := range objectRefPattern.FindAllStringSubmatch(value, -1) { + if len(match) != 2 || !strings.HasPrefix(match[0], objectRefPrefix) { + continue + } + assetID, err := uuid.Parse(match[1]) + if err != nil { + continue + } + if _, ok := seen[assetID]; ok { + continue + } + seen[assetID] = struct{}{} + assetIDs = append(assetIDs, assetID) + } + } + return assetIDs +} + +func projectWorkspaceID(project models.Project) uuid.UUID { + if project.WorkspaceID != nil && *project.WorkspaceID != uuid.Nil { + return *project.WorkspaceID + } + return models.PersonalWorkspaceID(project.UserID) +} diff --git a/backend/internal/services/project/media_usage_test.go b/backend/internal/services/project/mediausage/media_usage_test.go similarity index 79% rename from backend/internal/services/project/media_usage_test.go rename to backend/internal/services/project/mediausage/media_usage_test.go index 41898898..aecbb0b3 100644 --- a/backend/internal/services/project/media_usage_test.go +++ b/backend/internal/services/project/mediausage/media_usage_test.go @@ -1,4 +1,4 @@ -package project +package mediausage import ( "testing" @@ -10,7 +10,7 @@ import ( "github.com/kurodakayn/mpp-backend/internal/services/testsupport" ) -func TestUpsertMediaUsagesIgnoresDuplicateAssetResourceRefs(t *testing.T) { +func TestUpsertIgnoresDuplicateAssetResourceRefs(t *testing.T) { db := testsupport.SetupTestDB() require.NoError(t, db.AutoMigrate(&models.MediaAsset{})) @@ -40,8 +40,8 @@ func TestUpsertMediaUsagesIgnoresDuplicateAssetResourceRefs(t *testing.T) { require.NoError(t, db.Create(&asset).Error) resourceID := uuid.New() - require.NoError(t, upsertMediaUsages(db, workspace.ID, nil, nil, nil, "template", resourceID, models.MediaAssetUsageEditorImage, []uuid.UUID{asset.ID})) - require.NoError(t, upsertMediaUsages(db, workspace.ID, nil, nil, nil, "template", resourceID, models.MediaAssetUsageCoverImage, []uuid.UUID{asset.ID})) + require.NoError(t, upsert(db, workspace.ID, nil, nil, nil, "template", resourceID, models.MediaAssetUsageEditorImage, []uuid.UUID{asset.ID})) + require.NoError(t, upsert(db, workspace.ID, nil, nil, nil, "template", resourceID, models.MediaAssetUsageCoverImage, []uuid.UUID{asset.ID})) var count int64 require.NoError(t, db.Model(&models.MediaAssetUsage{}). diff --git a/backend/internal/services/project/platforms.go b/backend/internal/services/project/platforms.go deleted file mode 100644 index b73dd8e2..00000000 --- a/backend/internal/services/project/platforms.go +++ /dev/null @@ -1,25 +0,0 @@ -package project - -import "strings" - -func NormalizeProjectPlatforms(input []string) ([]string, error) { - seen := map[string]struct{}{} - platforms := make([]string, 0, len(input)) - - for _, raw := range input { - platform := strings.TrimSpace(raw) - if platform == "" { - continue - } - if _, ok := allowedProjectPlatforms[platform]; !ok { - return nil, ErrInvalidProject - } - if _, ok := seen[platform]; ok { - continue - } - seen[platform] = struct{}{} - platforms = append(platforms, platform) - } - - return platforms, nil -} diff --git a/backend/internal/services/project/publication/publication.go b/backend/internal/services/project/publication/publication.go new file mode 100644 index 00000000..2dc87dfe --- /dev/null +++ b/backend/internal/services/project/publication/publication.go @@ -0,0 +1,124 @@ +package publication + +import ( + "encoding/json" + "strings" + + "gorm.io/datatypes" + + "github.com/kurodakayn/mpp-backend/internal/dto" + "github.com/kurodakayn/mpp-backend/internal/models" + platformcapabilities "github.com/kurodakayn/mpp-backend/internal/platformcapabilities" + "github.com/kurodakayn/mpp-backend/internal/services/project/contentsetup" + "github.com/kurodakayn/mpp-backend/internal/services/project/projecterr" + "github.com/kurodakayn/mpp-backend/internal/services/project/publicationselection" + "github.com/kurodakayn/mpp-backend/internal/services/publicationpayload" +) + +var allowedPlatforms = platformcapabilities.ProjectPlatformSet() + +func NormalizePlatforms(input []string) ([]string, error) { + seen := map[string]struct{}{} + platforms := make([]string, 0, len(input)) + + for _, raw := range input { + platform := strings.TrimSpace(raw) + if platform == "" { + continue + } + if _, ok := allowedPlatforms[platform]; !ok { + return nil, projecterr.ErrInvalidProject + } + if _, ok := seen[platform]; ok { + continue + } + seen[platform] = struct{}{} + platforms = append(platforms, platform) + } + + return platforms, nil +} + +func PendingConfigForTemplate(title, summary, coverImageURL string, template *models.ContentTemplate) publicationselection.ConfigForPlatform { + return func(platform string) (datatypes.JSON, error) { + config, err := publicationpayload.DefaultConfig(title, summary, coverImageURL) + if err != nil { + return nil, err + } + return contentsetup.MergePublicationConfig(config, contentsetup.ContentTemplatePlatformConfig(template, platform)) + } +} + +func DefaultConfigForProjectTitle(title string) publicationselection.ConfigForPlatform { + return func(string) (datatypes.JSON, error) { + return publicationpayload.DefaultConfig(title, "", "") + } +} + +func DetailFromModel(pub models.ProjectPlatformPublication, includeContent bool) dto.PublicationDetail { + return detailFromModel(pub, includeContent, true) +} + +func ResponseDetailFromModel(pub models.ProjectPlatformPublication, includeContent bool) dto.PublicationDetail { + return detailFromModel(pub, includeContent, false) +} + +func detailFromModel(pub models.ProjectPlatformPublication, includeContent bool, normalizeEmptyContent bool) dto.PublicationDetail { + var rawConfig map[string]any + _ = json.Unmarshal(pub.Config, &rawConfig) + safeConfig := FilterConfig(rawConfig) + + var rawContent map[string]any + _ = json.Unmarshal(pub.AdaptedContent, &rawContent) + safeContent := rawContent + if !includeContent { + safeContent = SummarizeAdaptedContent(rawContent) + } + if normalizeEmptyContent && safeContent == nil { + safeContent = map[string]any{} + } + + return dto.PublicationDetail{ + ID: pub.ID, + Platform: pub.Platform, + Enabled: pub.Enabled, + Status: pub.Status, + DraftStatus: pub.DraftStatus, + ReviewStatus: pub.ReviewStatus, + SyncRequired: pub.SyncRequired, + ErrorMessage: pub.ErrorMessage, + Config: safeConfig, + AdaptedContent: safeContent, + PublishURL: pub.PublishURL, + RemoteID: pub.RemoteID, + RetryCount: pub.RetryCount, + LastAttemptAt: pub.LastAttemptAt, + PublishedAt: pub.PublishedAt, + CreatedAt: pub.CreatedAt, + UpdatedAt: pub.UpdatedAt, + } +} + +func FilterConfig(raw map[string]any) map[string]any { + safe := make(map[string]any) + allowedKeys := []string{"title", "tags", "cover_image", "topics", "category", "original_declaration", "username"} + for _, key := range allowedKeys { + if val, ok := raw[key]; ok { + safe[key] = val + } + } + return safe +} + +func SummarizeAdaptedContent(raw map[string]any) map[string]any { + safe := make(map[string]any) + if summary, ok := raw["summary"]; ok { + safe["summary"] = summary + } else { + safe["summary"] = "Content adapted (no summary available)" + } + if format, ok := raw["format"]; ok { + safe["format"] = format + } + return safe +} diff --git a/backend/internal/services/project/publication/publication_test.go b/backend/internal/services/project/publication/publication_test.go new file mode 100644 index 00000000..e85a7ce6 --- /dev/null +++ b/backend/internal/services/project/publication/publication_test.go @@ -0,0 +1,33 @@ +package publication + +import ( + "testing" + + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + + "github.com/kurodakayn/mpp-backend/internal/models" +) + +func TestDetailFromModelNormalizesEmptyContent(t *testing.T) { + detail := DetailFromModel(models.ProjectPlatformPublication{}, true) + + require.NotNil(t, detail.AdaptedContent) + require.Empty(t, detail.AdaptedContent) +} + +func TestResponseDetailFromModelPreservesEmptyIncludedContent(t *testing.T) { + detail := ResponseDetailFromModel(models.ProjectPlatformPublication{}, true) + + require.Nil(t, detail.AdaptedContent) +} + +func TestResponseDetailFromModelSummarizesExcludedContent(t *testing.T) { + pub := models.ProjectPlatformPublication{ + AdaptedContent: datatypes.JSON([]byte(`{"summary":"short","body":"hidden"}`)), + } + + detail := ResponseDetailFromModel(pub, false) + + require.Equal(t, map[string]any{"summary": "short"}, detail.AdaptedContent) +} diff --git a/backend/internal/services/project/publication_config.go b/backend/internal/services/project/publication_config.go deleted file mode 100644 index a51ef53d..00000000 --- a/backend/internal/services/project/publication_config.go +++ /dev/null @@ -1,25 +0,0 @@ -package project - -import ( - "gorm.io/datatypes" - - "github.com/kurodakayn/mpp-backend/internal/models" - "github.com/kurodakayn/mpp-backend/internal/services/project/publicationselection" - "github.com/kurodakayn/mpp-backend/internal/services/publicationpayload" -) - -func pendingPublicationConfigForTemplate(title, summary, coverImageURL string, template *models.ContentTemplate) publicationselection.ConfigForPlatform { - return func(platform string) (datatypes.JSON, error) { - config, err := publicationpayload.DefaultConfig(title, summary, coverImageURL) - if err != nil { - return nil, err - } - return mergePublicationConfig(config, contentTemplatePlatformConfig(template, platform)) - } -} - -func defaultPublicationConfigForProjectTitle(title string) publicationselection.ConfigForPlatform { - return func(string) (datatypes.JSON, error) { - return publicationpayload.DefaultConfig(title, "", "") - } -} diff --git a/backend/internal/services/project/publications.go b/backend/internal/services/project/publications.go index ea6878e8..ec61444f 100644 --- a/backend/internal/services/project/publications.go +++ b/backend/internal/services/project/publications.go @@ -1,14 +1,26 @@ package project import ( - "encoding/json" - "github.com/google/uuid" "github.com/kurodakayn/mpp-backend/internal/dto" "github.com/kurodakayn/mpp-backend/internal/models" + projectpublication "github.com/kurodakayn/mpp-backend/internal/services/project/publication" + "github.com/kurodakayn/mpp-backend/internal/services/project/publicationselection" ) +func NormalizeProjectPlatforms(input []string) ([]string, error) { + return projectpublication.NormalizePlatforms(input) +} + +func pendingPublicationConfigForTemplate(title, summary, coverImageURL string, template *models.ContentTemplate) publicationselection.ConfigForPlatform { + return projectpublication.PendingConfigForTemplate(title, summary, coverImageURL, template) +} + +func defaultPublicationConfigForProjectTitle(title string) publicationselection.ConfigForPlatform { + return projectpublication.DefaultConfigForProjectTitle(title) +} + func (s *Service) GetProjectPublications(projectID uuid.UUID, scopeUserID *uuid.UUID, includeContent bool) (*dto.ProjectPublicationsResponse, error) { readDB := s.projectDetailReadDB(scopeUserID) @@ -31,38 +43,7 @@ func (s *Service) GetProjectPublications(projectID uuid.UUID, scopeUserID *uuid. var items []dto.PublicationDetail for _, pub := range publications { - // Safe parse config - var rawConfig map[string]any - _ = json.Unmarshal(pub.Config, &rawConfig) - safeConfig := filterConfig(rawConfig) - - // Safe parse adapted content - var rawContent map[string]any - _ = json.Unmarshal(pub.AdaptedContent, &rawContent) - safeContent := rawContent - if !includeContent { - safeContent = summarizeAdaptedContent(rawContent) - } - - items = append(items, dto.PublicationDetail{ - ID: pub.ID, - Platform: pub.Platform, - Enabled: pub.Enabled, - Status: pub.Status, - DraftStatus: pub.DraftStatus, - ReviewStatus: pub.ReviewStatus, - SyncRequired: pub.SyncRequired, - ErrorMessage: pub.ErrorMessage, - Config: safeConfig, - AdaptedContent: safeContent, - PublishURL: pub.PublishURL, - RemoteID: pub.RemoteID, - RetryCount: pub.RetryCount, - LastAttemptAt: pub.LastAttemptAt, - PublishedAt: pub.PublishedAt, - CreatedAt: pub.CreatedAt, - UpdatedAt: pub.UpdatedAt, - }) + items = append(items, projectpublication.ResponseDetailFromModel(pub, includeContent)) } if items == nil { @@ -74,29 +55,3 @@ func (s *Service) GetProjectPublications(projectID uuid.UUID, scopeUserID *uuid. Items: items, }, nil } - -// Helper functions to filter sensitive data from JSONB fields - -func filterConfig(raw map[string]any) map[string]any { - safe := make(map[string]any) - allowedKeys := []string{"title", "tags", "cover_image", "topics", "category", "original_declaration", "username"} - for _, key := range allowedKeys { - if val, ok := raw[key]; ok { - safe[key] = val - } - } - return safe -} - -func summarizeAdaptedContent(raw map[string]any) map[string]any { - safe := make(map[string]any) - if summary, ok := raw["summary"]; ok { - safe["summary"] = summary - } else { - safe["summary"] = "Content adapted (no summary available)" - } - if format, ok := raw["format"]; ok { - safe["format"] = format - } - return safe -} diff --git a/backend/internal/services/project/service.go b/backend/internal/services/project/service.go index dac92891..04728619 100644 --- a/backend/internal/services/project/service.go +++ b/backend/internal/services/project/service.go @@ -14,7 +14,6 @@ import ( dbrouter "github.com/kurodakayn/mpp-backend/internal/db" "github.com/kurodakayn/mpp-backend/internal/models" "github.com/kurodakayn/mpp-backend/internal/pkg/redisdegrade" - platformcapabilities "github.com/kurodakayn/mpp-backend/internal/platformcapabilities" "github.com/kurodakayn/mpp-backend/internal/services/accesspolicy" collabdoc "github.com/kurodakayn/mpp-backend/internal/services/collabdoc" "github.com/kurodakayn/mpp-backend/internal/services/project/projecterr" @@ -38,8 +37,6 @@ type DashboardReadModelUpdater interface { RefreshWorkspaceAsync(ctx context.Context, workspaceID uuid.UUID) } -var allowedProjectPlatforms = platformcapabilities.ProjectPlatformSet() - type Service struct { db *gorm.DB router *dbrouter.Router