diff --git a/backend/internal/db/db_test.go b/backend/internal/db/db_test.go index 3431e1b9..8ba8b0c7 100644 --- a/backend/internal/db/db_test.go +++ b/backend/internal/db/db_test.go @@ -204,6 +204,86 @@ func TestProjectDomainRowsDeriveWorkspaceID(t *testing.T) { } require.NoError(t, database.Create(&state).Error) require.Equal(t, models.PersonalWorkspaceID(owner.ID), state.WorkspaceID) + + schedule := models.ScheduledPublication{ + ProjectID: project.ID, + PublicationID: publication.ID, + ScheduledAt: time.Now().UTC().Add(time.Hour), + CreatedBy: owner.ID, + } + require.NoError(t, database.Create(&schedule).Error) + require.Equal(t, workspace.ID, schedule.WorkspaceID) + + asset := models.MediaAsset{ + UserID: owner.ID, + ProjectID: &project.ID, + Bucket: "media", + ObjectKey: "workspaces/tenant/project/image.png", + OriginalFilename: "image.png", + MimeType: "image/png", + SizeBytes: 12, + Usage: models.MediaAssetUsageCoverImage, + } + require.NoError(t, database.Create(&asset).Error) + require.NotNil(t, asset.WorkspaceID) + require.Equal(t, workspace.ID, *asset.WorkspaceID) + + assetUsage := models.MediaAssetUsage{ + MediaAssetID: asset.ID, + ProjectID: &project.ID, + ResourceType: "project", + ResourceID: project.ID, + UsageKind: models.MediaAssetUsageCoverImage, + } + require.NoError(t, database.Create(&assetUsage).Error) + require.Equal(t, workspace.ID, assetUsage.WorkspaceID) + + snapshot := models.AIContextSnapshot{ + ProjectID: project.ID, + CreatedByID: owner.ID, + ContextKind: "drafting", + SourceContent: "content", + TokenEstimate: 1, + ContextBudget: 100, + CompactionLevel: "none", + } + require.NoError(t, database.Create(&snapshot).Error) + require.Equal(t, workspace.ID, snapshot.WorkspaceID) + + run := models.AIGrowthOptimizationRun{ + ProjectID: project.ID, + ContextSnapshotID: snapshot.ID, + Goal: "tighten", + Intensity: "balanced", + TargetPlatforms: []byte(`["wechat"]`), + Status: "running", + Model: "test-model", + PromptVersion: "test", + CreatedByID: owner.ID, + } + require.NoError(t, database.Create(&run).Error) + require.Equal(t, workspace.ID, run.WorkspaceID) + + proposal := models.AIProposal{ + ProjectID: project.ID, + RunID: &run.ID, + ContextSnapshotID: snapshot.ID, + ProposalType: "source_rewrite", + TargetPlatform: "wechat", + Status: "proposed", + } + require.NoError(t, database.Create(&proposal).Error) + require.Equal(t, workspace.ID, proposal.WorkspaceID) + + draftingSession := models.AIDraftingSession{ + ProjectID: project.ID, + CreatedByID: owner.ID, + Title: "Drafting", + Status: "active", + LastMessageAt: time.Now().UTC(), + } + require.NoError(t, database.Create(&draftingSession).Error) + require.Equal(t, workspace.ID, draftingSession.WorkspaceID) } func TestSyncSchemaAddsArchiveScanIndexes(t *testing.T) { diff --git a/backend/internal/models/ai_hooks.go b/backend/internal/models/ai_hooks.go new file mode 100644 index 00000000..4eef4edc --- /dev/null +++ b/backend/internal/models/ai_hooks.go @@ -0,0 +1,46 @@ +package models + +import ( + "github.com/google/uuid" + "gorm.io/gorm" +) + +func (s *AIContextSnapshot) BeforeCreate(tx *gorm.DB) (err error) { + if s.ID == uuid.Nil { + s.ID = uuid.New() + } + if s.WorkspaceID == uuid.Nil { + s.WorkspaceID = deriveWorkspaceIDFromProject(tx, s.ProjectID, s.CreatedByID) + } + return +} + +func (r *AIGrowthOptimizationRun) BeforeCreate(tx *gorm.DB) (err error) { + if r.ID == uuid.Nil { + r.ID = uuid.New() + } + if r.WorkspaceID == uuid.Nil { + r.WorkspaceID = deriveWorkspaceIDFromProject(tx, r.ProjectID, r.CreatedByID) + } + return +} + +func (p *AIProposal) BeforeCreate(tx *gorm.DB) (err error) { + if p.ID == uuid.Nil { + p.ID = uuid.New() + } + if p.WorkspaceID == uuid.Nil { + p.WorkspaceID = deriveWorkspaceIDFromProject(tx, p.ProjectID, uuid.Nil) + } + return +} + +func (s *AIDraftingSession) BeforeCreate(tx *gorm.DB) (err error) { + if s.ID == uuid.Nil { + s.ID = uuid.New() + } + if s.WorkspaceID == uuid.Nil { + s.WorkspaceID = deriveWorkspaceIDFromProject(tx, s.ProjectID, s.CreatedByID) + } + return +} diff --git a/backend/internal/models/hooks.go b/backend/internal/models/hooks.go new file mode 100644 index 00000000..cba85602 --- /dev/null +++ b/backend/internal/models/hooks.go @@ -0,0 +1,322 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +func (u *User) BeforeCreate(_ *gorm.DB) (err error) { + if u.ID == uuid.Nil { + u.ID = uuid.New() + } + return +} + +func (p *Project) BeforeCreate(_ *gorm.DB) (err error) { + if p.ID == uuid.Nil { + p.ID = uuid.New() + } + return +} + +func (m *MediaAsset) BeforeCreate(tx *gorm.DB) (err error) { + if m.ID == uuid.Nil { + m.ID = uuid.New() + } + if m.WorkspaceID == nil { + workspaceID := deriveWorkspaceIDFromMediaAsset(tx, uuid.Nil, m.ProjectID, m.UserID) + if workspaceID != uuid.Nil { + m.WorkspaceID = &workspaceID + } + } + if m.Status == "" { + m.Status = MediaAssetStatusPending + } + if m.LibraryScope == "" { + m.LibraryScope = MediaAssetLibraryScopeProject + } + if m.Tags == nil { + m.Tags = datatypes.JSON([]byte(`[]`)) + } + return +} + +func (t *ContentTemplate) BeforeCreate(_ *gorm.DB) (err error) { + if t.ID == uuid.Nil { + t.ID = uuid.New() + } + if t.Scope == "" { + t.Scope = ContentTemplateScopePersonal + } + if t.DefaultPlatforms == nil { + t.DefaultPlatforms = datatypes.JSON([]byte(`[]`)) + } + if t.PlatformConfig == nil { + t.PlatformConfig = datatypes.JSON([]byte(`{}`)) + } + if t.Tags == nil { + t.Tags = datatypes.JSON([]byte(`[]`)) + } + return +} + +func (b *BrandProfile) BeforeCreate(_ *gorm.DB) (err error) { + if b.ID == uuid.Nil { + b.ID = uuid.New() + } + if b.BannedWords == nil { + b.BannedWords = datatypes.JSON([]byte(`[]`)) + } + if b.DefaultTags == nil { + b.DefaultTags = datatypes.JSON([]byte(`[]`)) + } + return +} + +func (u *MediaAssetUsage) BeforeCreate(tx *gorm.DB) (err error) { + if u.ID == uuid.Nil { + u.ID = uuid.New() + } + if u.WorkspaceID == uuid.Nil { + u.WorkspaceID = deriveWorkspaceIDFromMediaAsset(tx, u.MediaAssetID, u.ProjectID, uuid.Nil) + } + return +} + +func (p *ProjectPlatformPublication) BeforeCreate(tx *gorm.DB) (err error) { + if p.ID == uuid.Nil { + p.ID = uuid.New() + } + if p.WorkspaceID == uuid.Nil { + p.WorkspaceID = deriveWorkspaceIDFromProject(tx, p.ProjectID, uuid.Nil) + } + if p.DraftStatus == "" { + p.DraftStatus = PublicationDraftStatusUnsynced + } + if p.ReviewStatus == "" { + p.ReviewStatus = PublicationReviewStatusDraft + } + return +} + +func (e *PublishEvent) BeforeCreate(tx *gorm.DB) (err error) { + if e.ID == uuid.Nil { + e.ID = uuid.New() + } + if e.WorkspaceID == uuid.Nil { + e.WorkspaceID = deriveWorkspaceIDFromProject(tx, e.ProjectID, e.UserID) + } + if e.CreatedAt.IsZero() { + e.CreatedAt = time.Now().UTC() + } + return +} + +func (s *ScheduledPublication) BeforeCreate(tx *gorm.DB) (err error) { + if s.ID == uuid.Nil { + s.ID = uuid.New() + } + if s.WorkspaceID == uuid.Nil { + s.WorkspaceID = deriveWorkspaceIDFromProject(tx, s.ProjectID, s.CreatedBy) + } + if s.Status == "" { + s.Status = ScheduledPublicationStatusScheduled + } + if s.Timezone == "" { + s.Timezone = "UTC" + } + return +} + +func (a *PublishAttempt) BeforeCreate(_ *gorm.DB) (err error) { + if a.ID == uuid.Nil { + a.ID = uuid.New() + } + if a.Status == "" { + a.Status = PublishAttemptStatusRunning + } + return +} + +func (e *OutboxEvent) BeforeCreate(_ *gorm.DB) (err error) { + if e.ID == uuid.Nil { + e.ID = uuid.New() + } + if e.Payload == nil { + e.Payload = datatypes.JSON([]byte(`{}`)) + } + if e.Status == "" { + e.Status = OutboxStatusPending + } + return +} + +func (w *Workspace) BeforeCreate(_ *gorm.DB) (err error) { + if w.ID == uuid.Nil { + w.ID = uuid.New() + } + if w.Status == "" { + w.Status = WorkspaceStatusActive + } + return +} + +func (a *WorkspaceActivity) BeforeCreate(_ *gorm.DB) (err error) { + if a.ID == uuid.Nil { + a.ID = uuid.New() + } + if a.CreatedAt.IsZero() { + a.CreatedAt = time.Now().UTC() + } + return +} + +func (n *Notification) BeforeCreate(_ *gorm.DB) (err error) { + if n.ID == uuid.Nil { + n.ID = uuid.New() + } + if n.Status == "" { + n.Status = NotificationStatusUnread + } + return +} + +func (i *WorkspaceInvite) BeforeCreate(_ *gorm.DB) (err error) { + if i.ID == uuid.Nil { + i.ID = uuid.New() + } + if i.Status == "" { + i.Status = WorkspaceInviteStatusPending + } + return +} + +func (a *ProjectActivity) BeforeCreate(tx *gorm.DB) (err error) { + if a.ID == uuid.Nil { + a.ID = uuid.New() + } + if a.WorkspaceID == uuid.Nil { + a.WorkspaceID = deriveWorkspaceIDFromProject(tx, a.ProjectID, a.ActorUserID) + } + if a.CreatedAt.IsZero() { + a.CreatedAt = time.Now().UTC() + } + return +} + +func (c *ProjectComment) BeforeCreate(tx *gorm.DB) (err error) { + if c.ID == uuid.Nil { + c.ID = uuid.New() + } + if c.WorkspaceID == uuid.Nil { + c.WorkspaceID = deriveWorkspaceIDFromProject(tx, c.ProjectID, c.AuthorID) + } + if c.Status == "" { + c.Status = ProjectCommentStatusOpen + } + return +} + +func (v *ProjectVersion) BeforeCreate(tx *gorm.DB) (err error) { + if v.ID == uuid.Nil { + v.ID = uuid.New() + } + if v.WorkspaceID == uuid.Nil { + v.WorkspaceID = deriveWorkspaceIDFromProject(tx, v.ProjectID, v.CreatedBy) + } + return +} + +func (l *ProjectShareLink) BeforeCreate(tx *gorm.DB) (err error) { + if l.ID == uuid.Nil { + l.ID = uuid.New() + } + if l.WorkspaceID == uuid.Nil { + l.WorkspaceID = deriveWorkspaceIDFromProject(tx, l.ProjectID, l.CreatedBy) + } + if l.Status == "" { + l.Status = ProjectShareLinkStatusActive + } + return +} + +func (pa *PlatformAccount) BeforeCreate(_ *gorm.DB) (err error) { + if pa.ID == uuid.Nil { + pa.ID = uuid.New() + } + if pa.OwnerUserID == nil && pa.UserID != uuid.Nil { + ownerID := pa.UserID + pa.OwnerUserID = &ownerID + } + if pa.ConnectedByUserID == nil && pa.UserID != uuid.Nil { + connectedBy := pa.UserID + pa.ConnectedByUserID = &connectedBy + } + if pa.WorkspaceID == nil && pa.UserID != uuid.Nil { + workspaceID := PersonalWorkspaceID(pa.UserID) + pa.WorkspaceID = &workspaceID + } + if pa.DisplayName == "" { + pa.DisplayName = pa.Username + } + if pa.ShareScope == "" { + pa.ShareScope = PlatformAccountSharePrivate + } + if pa.HealthStatus == "" { + pa.HealthStatus = PlatformAccountHealthUnknown + } + if pa.CredentialSecretRef == "" { + pa.CredentialSecretRef = "platform-account:" + pa.ID.String() + } + return +} + +func (s *RemoteBrowserSession) BeforeCreate(_ *gorm.DB) (err error) { + if s.ID == uuid.Nil { + s.ID = uuid.New() + } + if s.CreatedAt.IsZero() { + s.CreatedAt = time.Now().UTC() + } + return +} + +func (g *PlatformAccountGrant) BeforeCreate(_ *gorm.DB) (err error) { + if g.ID == uuid.Nil { + g.ID = uuid.New() + } + return +} + +func (t *ExtensionCallbackToken) BeforeCreate(tx *gorm.DB) (err error) { + if t.ID == uuid.Nil { + t.ID = uuid.New() + } + if t.WorkspaceID == uuid.Nil { + t.WorkspaceID = deriveWorkspaceIDFromProject(tx, t.ProjectID, t.UserID) + } + return +} + +func (e *ExtensionExecutionEvent) BeforeCreate(tx *gorm.DB) (err error) { + if e.ID == uuid.Nil { + e.ID = uuid.New() + } + if e.WorkspaceID == uuid.Nil { + e.WorkspaceID = deriveWorkspaceIDFromProject(tx, e.ProjectID, e.UserID) + } + if e.CreatedAt.IsZero() { + e.CreatedAt = time.Now().UTC() + } + return +} + +func (c *ExtensionExecutionEventClaim) BeforeCreate(_ *gorm.DB) (err error) { + if c.CreatedAt.IsZero() { + c.CreatedAt = time.Now().UTC() + } + return +} diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index c852da26..c41b84a0 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -683,305 +683,3 @@ type ExtensionExecutionEventClaim struct { RecordID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex"` CreatedAt time.Time `gorm:"not null;index"` } - -// BeforeCreate hook to generate UUID if not set -func (u *User) BeforeCreate(_ *gorm.DB) (err error) { - if u.ID == uuid.Nil { - u.ID = uuid.New() - } - return -} - -func (p *Project) BeforeCreate(_ *gorm.DB) (err error) { - if p.ID == uuid.Nil { - p.ID = uuid.New() - } - return -} - -func (m *MediaAsset) BeforeCreate(_ *gorm.DB) (err error) { - if m.ID == uuid.Nil { - m.ID = uuid.New() - } - if m.Status == "" { - m.Status = MediaAssetStatusPending - } - if m.LibraryScope == "" { - m.LibraryScope = MediaAssetLibraryScopeProject - } - if m.Tags == nil { - m.Tags = datatypes.JSON([]byte(`[]`)) - } - return -} - -func (t *ContentTemplate) BeforeCreate(_ *gorm.DB) (err error) { - if t.ID == uuid.Nil { - t.ID = uuid.New() - } - if t.Scope == "" { - t.Scope = ContentTemplateScopePersonal - } - if t.DefaultPlatforms == nil { - t.DefaultPlatforms = datatypes.JSON([]byte(`[]`)) - } - if t.PlatformConfig == nil { - t.PlatformConfig = datatypes.JSON([]byte(`{}`)) - } - if t.Tags == nil { - t.Tags = datatypes.JSON([]byte(`[]`)) - } - return -} - -func (b *BrandProfile) BeforeCreate(_ *gorm.DB) (err error) { - if b.ID == uuid.Nil { - b.ID = uuid.New() - } - if b.BannedWords == nil { - b.BannedWords = datatypes.JSON([]byte(`[]`)) - } - if b.DefaultTags == nil { - b.DefaultTags = datatypes.JSON([]byte(`[]`)) - } - return -} - -func (u *MediaAssetUsage) BeforeCreate(_ *gorm.DB) (err error) { - if u.ID == uuid.Nil { - u.ID = uuid.New() - } - return -} - -func (p *ProjectPlatformPublication) BeforeCreate(tx *gorm.DB) (err error) { - if p.ID == uuid.Nil { - p.ID = uuid.New() - } - if p.WorkspaceID == uuid.Nil { - p.WorkspaceID = deriveWorkspaceIDFromProject(tx, p.ProjectID, uuid.Nil) - } - if p.DraftStatus == "" { - p.DraftStatus = PublicationDraftStatusUnsynced - } - if p.ReviewStatus == "" { - p.ReviewStatus = PublicationReviewStatusDraft - } - return -} - -func (e *PublishEvent) BeforeCreate(tx *gorm.DB) (err error) { - if e.ID == uuid.Nil { - e.ID = uuid.New() - } - if e.WorkspaceID == uuid.Nil { - e.WorkspaceID = deriveWorkspaceIDFromProject(tx, e.ProjectID, e.UserID) - } - if e.CreatedAt.IsZero() { - e.CreatedAt = time.Now().UTC() - } - return -} - -func (s *ScheduledPublication) BeforeCreate(_ *gorm.DB) (err error) { - if s.ID == uuid.Nil { - s.ID = uuid.New() - } - if s.Status == "" { - s.Status = ScheduledPublicationStatusScheduled - } - if s.Timezone == "" { - s.Timezone = "UTC" - } - return -} - -func (a *PublishAttempt) BeforeCreate(_ *gorm.DB) (err error) { - if a.ID == uuid.Nil { - a.ID = uuid.New() - } - if a.Status == "" { - a.Status = PublishAttemptStatusRunning - } - return -} - -func (e *OutboxEvent) BeforeCreate(_ *gorm.DB) (err error) { - if e.ID == uuid.Nil { - e.ID = uuid.New() - } - if e.Payload == nil { - e.Payload = datatypes.JSON([]byte(`{}`)) - } - if e.Status == "" { - e.Status = OutboxStatusPending - } - return -} - -func (w *Workspace) BeforeCreate(_ *gorm.DB) (err error) { - if w.ID == uuid.Nil { - w.ID = uuid.New() - } - if w.Status == "" { - w.Status = WorkspaceStatusActive - } - return -} - -func (a *WorkspaceActivity) BeforeCreate(_ *gorm.DB) (err error) { - if a.ID == uuid.Nil { - a.ID = uuid.New() - } - if a.CreatedAt.IsZero() { - a.CreatedAt = time.Now().UTC() - } - return -} - -func (n *Notification) BeforeCreate(_ *gorm.DB) (err error) { - if n.ID == uuid.Nil { - n.ID = uuid.New() - } - if n.Status == "" { - n.Status = NotificationStatusUnread - } - return -} - -func (i *WorkspaceInvite) BeforeCreate(_ *gorm.DB) (err error) { - if i.ID == uuid.Nil { - i.ID = uuid.New() - } - if i.Status == "" { - i.Status = WorkspaceInviteStatusPending - } - return -} - -func (a *ProjectActivity) BeforeCreate(tx *gorm.DB) (err error) { - if a.ID == uuid.Nil { - a.ID = uuid.New() - } - if a.WorkspaceID == uuid.Nil { - a.WorkspaceID = deriveWorkspaceIDFromProject(tx, a.ProjectID, a.ActorUserID) - } - if a.CreatedAt.IsZero() { - a.CreatedAt = time.Now().UTC() - } - return -} - -func (c *ProjectComment) BeforeCreate(tx *gorm.DB) (err error) { - if c.ID == uuid.Nil { - c.ID = uuid.New() - } - if c.WorkspaceID == uuid.Nil { - c.WorkspaceID = deriveWorkspaceIDFromProject(tx, c.ProjectID, c.AuthorID) - } - if c.Status == "" { - c.Status = ProjectCommentStatusOpen - } - return -} - -func (v *ProjectVersion) BeforeCreate(tx *gorm.DB) (err error) { - if v.ID == uuid.Nil { - v.ID = uuid.New() - } - if v.WorkspaceID == uuid.Nil { - v.WorkspaceID = deriveWorkspaceIDFromProject(tx, v.ProjectID, v.CreatedBy) - } - return -} - -func (l *ProjectShareLink) BeforeCreate(tx *gorm.DB) (err error) { - if l.ID == uuid.Nil { - l.ID = uuid.New() - } - if l.WorkspaceID == uuid.Nil { - l.WorkspaceID = deriveWorkspaceIDFromProject(tx, l.ProjectID, l.CreatedBy) - } - if l.Status == "" { - l.Status = ProjectShareLinkStatusActive - } - return -} - -func (pa *PlatformAccount) BeforeCreate(_ *gorm.DB) (err error) { - if pa.ID == uuid.Nil { - pa.ID = uuid.New() - } - if pa.OwnerUserID == nil && pa.UserID != uuid.Nil { - ownerID := pa.UserID - pa.OwnerUserID = &ownerID - } - if pa.ConnectedByUserID == nil && pa.UserID != uuid.Nil { - connectedBy := pa.UserID - pa.ConnectedByUserID = &connectedBy - } - if pa.WorkspaceID == nil && pa.UserID != uuid.Nil { - workspaceID := PersonalWorkspaceID(pa.UserID) - pa.WorkspaceID = &workspaceID - } - if pa.DisplayName == "" { - pa.DisplayName = pa.Username - } - if pa.ShareScope == "" { - pa.ShareScope = PlatformAccountSharePrivate - } - if pa.HealthStatus == "" { - pa.HealthStatus = PlatformAccountHealthUnknown - } - if pa.CredentialSecretRef == "" { - pa.CredentialSecretRef = "platform-account:" + pa.ID.String() - } - return -} - -func (s *RemoteBrowserSession) BeforeCreate(_ *gorm.DB) (err error) { - if s.ID == uuid.Nil { - s.ID = uuid.New() - } - if s.CreatedAt.IsZero() { - s.CreatedAt = time.Now().UTC() - } - return -} - -func (g *PlatformAccountGrant) BeforeCreate(_ *gorm.DB) (err error) { - if g.ID == uuid.Nil { - g.ID = uuid.New() - } - return -} - -func (t *ExtensionCallbackToken) BeforeCreate(tx *gorm.DB) (err error) { - if t.ID == uuid.Nil { - t.ID = uuid.New() - } - if t.WorkspaceID == uuid.Nil { - t.WorkspaceID = deriveWorkspaceIDFromProject(tx, t.ProjectID, t.UserID) - } - return -} - -func (e *ExtensionExecutionEvent) BeforeCreate(tx *gorm.DB) (err error) { - if e.ID == uuid.Nil { - e.ID = uuid.New() - } - if e.WorkspaceID == uuid.Nil { - e.WorkspaceID = deriveWorkspaceIDFromProject(tx, e.ProjectID, e.UserID) - } - if e.CreatedAt.IsZero() { - e.CreatedAt = time.Now().UTC() - } - return -} - -func (c *ExtensionExecutionEventClaim) BeforeCreate(_ *gorm.DB) (err error) { - if c.CreatedAt.IsZero() { - c.CreatedAt = time.Now().UTC() - } - return -} diff --git a/backend/internal/models/workspace_identity.go b/backend/internal/models/workspace_identity.go index 4cf0fbeb..06f801a6 100644 --- a/backend/internal/models/workspace_identity.go +++ b/backend/internal/models/workspace_identity.go @@ -58,3 +58,34 @@ func deriveWorkspaceIDFromDocument(db *gorm.DB, documentID uuid.UUID) uuid.UUID } return uuid.Nil } + +func deriveWorkspaceIDFromMediaAsset(db *gorm.DB, mediaAssetID uuid.UUID, projectID *uuid.UUID, fallbackUserID uuid.UUID) uuid.UUID { + if projectID != nil && *projectID != uuid.Nil { + return deriveWorkspaceIDFromProject(db, *projectID, fallbackUserID) + } + + if mediaAssetID == uuid.Nil { + if fallbackUserID != uuid.Nil { + return PersonalWorkspaceID(fallbackUserID) + } + return uuid.Nil + } + + var asset MediaAsset + if err := db.Select("id", "user_id", "workspace_id", "project_id").First(&asset, "id = ?", mediaAssetID).Error; err == nil { + if asset.WorkspaceID != nil && *asset.WorkspaceID != uuid.Nil { + return *asset.WorkspaceID + } + if asset.ProjectID != nil && *asset.ProjectID != uuid.Nil { + return deriveWorkspaceIDFromProject(db, *asset.ProjectID, asset.UserID) + } + if asset.UserID != uuid.Nil { + return PersonalWorkspaceID(asset.UserID) + } + } + + if fallbackUserID != uuid.Nil { + return PersonalWorkspaceID(fallbackUserID) + } + return uuid.Nil +} diff --git a/doc/plan/database-optimization.md b/doc/plan/database-optimization.md index d44bd7d6..80eb056a 100644 --- a/doc/plan/database-optimization.md +++ b/doc/plan/database-optimization.md @@ -60,14 +60,14 @@ Atomic commit guidance: | Application connection pools | Done | backend/publish-worker support `DB_MAX_OPEN_CONNS`, `DB_MAX_IDLE_CONNS`, `DB_CONN_MAX_LIFETIME`, and `DB_CONN_MAX_IDLE_TIME`; collab-service node-postgres pool supports `DB_MAX_OPEN_CONNS`, `DB_CONN_MAX_LIFETIME`, and `DB_CONN_MAX_IDLE_TIME`; Redis client supports `REDIS_POOL_SIZE`, `REDIS_MIN_IDLE_CONNS`, `REDIS_MAX_IDLE_CONNS`, `REDIS_CONN_MAX_IDLE_TIME`, and `REDIS_CONN_MAX_LIFETIME`; Docker Compose and self-hosted Kubernetes reuse PostgreSQL connections through PgBouncer writer pool; GORM PostgreSQL driver uses simple protocol to remain compatible with transaction pooling; self-hosted Kubernetes added PgBouncer reader pool and points app `DB_READER_HOST` to the reader pool | None | `backend/internal/db/db.go`, `backend/internal/redisclient/redisclient.go`, `collab-service/src/config.ts`, `collab-service/src/persistence/document-persistence.ts`, `collab-service/src/persistence/document-persistence.test.ts`, `deploy/docker/docker-compose.yml`, `deploy/kubernetes/data-services/self-hosted/pgbouncer.yaml`, `deploy/kubernetes/app-baseline/app-config.yaml` | | Query observability | Done | GORM QueryObserver, slow-query logs, `mpp_db_queries_total`, `mpp_db_query_duration_seconds`, `mpp_db_slow_queries_total`, self-hosted PostgreSQL `pg_stat_statements`, PostgreSQL exporter table-level health metrics, Grafana table row count / 24h growth / table size / index size / dead tuples / vacuum panels | Threshold alerts can continue to be added by production baseline in Phase 1/4 | `backend/internal/db/query_observer.go`, `backend/internal/observability/observability.go`, `script/db/audit_database_baseline.sql`, `deploy/docker/observability/postgres-exporter/queries.yml`, `deploy/docker/observability/grafana/dashboards/mpp-observability-baseline.json` | | Dashboard query audit | Done | Existing audit script covers dashboard Count, list, platform filter, publication preload, account query, and active session query plans | Not yet turned into a periodic CI/ops gate | `script/db/audit_dashboard_query_plans.sql` | -| Tenant boundary | In progress | Existing `workspaces`, `workspace_members`, `projects.workspace_id`, and personal workspace rules; project-domain publishing, activity, comment, version, share-link, extension, and collaboration rows now carry `workspace_id` directly, with model hooks or service write paths deriving it from the owning project or document for new writes | Citus colocation groups, distributed-table constraints, and worker payload ownership are still open | `backend/internal/models/models.go`, `backend/internal/models/collab.go`, `backend/internal/db/monthly_partitions.go`, `backend/internal/db/hash_partitions.go`, `collab-service/src/persistence/document-persistence.ts`, `backend/internal/db/db_test.go`, `collab-service/src/persistence/document-persistence.test.ts` | +| Tenant boundary | In progress | Existing `workspaces`, `workspace_members`, `projects.workspace_id`, and personal workspace rules; project-domain publishing, activity, comment, version, share-link, media, AI, extension, and collaboration rows now carry `workspace_id` directly, with model hooks or service write paths deriving it from the owning project, document, or media asset for new writes | Citus colocation groups, distributed-table constraints, and worker payload ownership are still open | `backend/internal/models/hooks.go`, `backend/internal/models/ai_hooks.go`, `backend/internal/models/collab.go`, `backend/internal/db/monthly_partitions.go`, `backend/internal/db/hash_partitions.go`, `collab-service/src/persistence/document-persistence.ts`, `backend/internal/db/db_test.go`, `collab-service/src/persistence/document-persistence.test.ts` | | Dashboard read models | Done | Added `workspace_dashboard_stats` and `project_list_summaries` read models, idempotently recomputed from fact tables by a centralized readmodel service; async refresh is triggered after project save, platform sync, publish completion, and member changes; admin stats and admin project list prefer read models when coverage is complete; admin rebuild API enqueues through Asynq, and API/worker processes can start readmodel workers for full rebuild from fact tables | None | `backend/internal/models/models.go`, `backend/internal/services/readmodel/service.go`, `backend/internal/services/readmodel/queue.go`, `backend/internal/services/readmodel/service_test.go`, `backend/internal/services/readmodel/queue_test.go`, `backend/internal/services/stats/overview.go`, `backend/internal/services/project/lifecycle.go`, `backend/internal/handlers/dashboard.go`, `backend/cmd/api/main.go`, `backend/cmd/publish-worker/main.go` | | Redis read cache | Done | Redis is already used for queues, locks, OAuth, browser sessions, and short-term coordination; admin dashboard stats, admin project list, and dashboard account summary use 15s TTL cache and bypass scoped/sticky-writer strong-consistency paths; stats/project list/account cache misses use singleflight to prevent process-local stampede; stats and account caches use versioned payloads and semantic validation, and Redis read-error fallback is also merged into one DB computation per key; project create/edit/platform save, prepublish sync/draft update, publish queue/execute/fail, and platform account write paths invalidate the related dashboard cache; full read-model rebuild reuses the Redis/Asynq queue | None | `backend/internal/services/stats/overview.go`, `backend/internal/services/stats/overview_test.go`, `backend/internal/services/project/list_cache.go`, `backend/internal/services/project/list_cache_test.go`, `backend/internal/services/prepublish/drafts.go`, `backend/internal/services/publish/service.go`, `backend/internal/services/publish/queue.go`, `backend/internal/services/publish/publication_flow_test.go`, `backend/internal/services/publish/queue_test.go`, `backend/internal/services/platform_account/account_cache.go`, `backend/internal/services/platform_account/account_cache_test.go`, `backend/internal/services/browser_session/complete.go`, `backend/internal/services/browser_session/service_test.go`, `backend/internal/services/readmodel/queue.go` | | Read/write splitting | Done | Supports optional `DB_READER_*` read-replica connection, `DefaultRouter`, and signed sticky writer; project/stats/workspace/platform_account/publish/prepublish/mediaasset/browser_session/extension are wired to strong/eventual/writer routing; dashboard, publish, and collab-service consistency-level inventories are complete, with collab-service online path kept writer-only; writer/reader pools are in self-hosted Kubernetes, and managed overlay provides a `postgres-reader` ExternalName entry point; `DB_READER_MAX_REPLICA_LAG` configures the replica lag threshold, eventual/analytics reads automatically fall back to writer when over threshold or lag is unknown, and `mpp_db_replica_lag_seconds` and `mpp_db_replica_healthy` metrics are exposed | None | `backend/internal/db/db.go`, `backend/internal/db/router.go`, `backend/internal/db/replica_lag.go`, `backend/internal/services/publish/service.go`, `backend/internal/services/prepublish/service.go`, `backend/internal/services/mediaasset/service.go`, `backend/internal/services/browser_session/service.go`, `backend/internal/services/extension/service.go`, `backend/internal/app/runtime.go`, `deploy/kubernetes/data-services/self-hosted/postgres.yaml`, `deploy/kubernetes/data-services/self-hosted/pgbouncer.yaml`, `deploy/kubernetes/data-services/managed/services.yaml`, `script/kubernetes/validation/data_services.rb` | | Event-table partitioning and archiving | Done | `publish_events`, `extension_execution_events`, `project_activities`, `workspace_activities`, and terminal `remote_browser_sessions` have default retention periods; the `archive` worker can batch-export JSONL to R2/S3 and delete old hot-table rows after successful upload; PostgreSQL schema initialization now creates monthly `created_at` partitions for `publish_events`, `extension_execution_events`, `project_activities`, `workspace_activities`, and `remote_browser_sessions`, with partition-compatible `(id, created_at)` primary keys and rolling partition creation; the archive worker exports whole cold monthly partitions as JSONL to R2/S3, then detaches and drops the partition after successful upload; PostgreSQL browser-session active-row fallback uses a scoped advisory transaction lock because partitioned unique constraints must include the partition key; the archive recovery procedure defines inspection, staging restore, optional hot-table reinsertion, and audit checks | None | `backend/internal/db/monthly_partitions.go`, `backend/internal/db/db.go`, `backend/internal/models/models.go`, `backend/internal/db/db_test.go`, `backend/internal/services/browser_session/start.go`, `backend/internal/services/browser_session/cleanup.go`, `backend/internal/services/archive/worker.go`, `backend/internal/services/archive/partitions.go`, `backend/internal/services/archive/worker_test.go`, `backend/internal/services/archive/partitions_test.go`, Phase 4 archive recovery procedure in this document | | Collaboration batch governance | In progress | `collab_document_states`, `collab_document_update_batches`, and compaction/retention foundations exist; PostgreSQL schema initialization creates a 16-way `document_id` hash-partitioned `collab_document_update_batches` target table and migrates existing regular-table rows into it | Cold archiving is not implemented | `backend/internal/db/hash_partitions.go`, `backend/internal/db/db.go`, `backend/internal/models/collab.go`, `backend/internal/db/db_test.go`, `collab-service/src/persistence/document-persistence.ts` | | Outbox/CDC/event stream | In progress | The publishing queue path has a transactional Outbox: `EnqueuePublishProject` writes `outbox_events` in the same transaction and dispatches immediately after commit; publish worker starts an outbox dispatcher and supports retries for failed/stale processing records; Asynq continues to serve as the task-execution queue, and `PublishEvent` continues to serve as publishing audit | Currently covers only `publish.job_requested`; general business-event outbox, Debezium, and Redpanda/Kafka CDC are not implemented | `backend/internal/services/publish/queue.go`, `backend/internal/services/publish/outbox.go`, `backend/internal/models/models.go` | -| Citus target state | In progress | Confirmed `workspace_id` as the most suitable distribution-column direction; project-domain tables now have explicit or stable derived tenant routing coverage | Citus distributed tables, reference tables, colocation, unique-constraint review, and worker payload routing are not implemented | Phase 5 checklist and `backend/internal/db/db_test.go` | +| Citus target state | In progress | Confirmed `workspace_id` as the most suitable distribution-column direction; project-domain tables now have explicit or stable derived tenant routing coverage, including direct-insert fallbacks for scheduled publishing, media, and AI rows | Citus distributed tables, reference tables, colocation, unique-constraint review, and worker payload routing are not implemented | Phase 5 checklist, `backend/internal/models/ai_hooks.go`, and `backend/internal/db/db_test.go` | ### 0.3 Phase Checklist @@ -127,7 +127,7 @@ Atomic commit guidance: - [x] Confirm Workspace as the long-term tenant boundary. - [x] Confirm `workspace_id` as the preferred Citus distribution column. -- [x] Complete `workspace_id` or a stable derivation path for project-domain tables. Verification entry point: `backend/internal/models/models.go`, `backend/internal/models/collab.go`, `backend/internal/db/monthly_partitions.go`, `backend/internal/db/hash_partitions.go`, `collab-service/src/persistence/document-persistence.ts`, `backend/internal/db/db_test.go`, `collab-service/src/persistence/document-persistence.test.ts`. +- [x] Complete `workspace_id` or a stable derivation path for project-domain tables. Verification entry point: `backend/internal/models/hooks.go`, `backend/internal/models/ai_hooks.go`, `backend/internal/models/collab.go`, `backend/internal/db/monthly_partitions.go`, `backend/internal/db/hash_partitions.go`, `collab-service/src/persistence/document-persistence.ts`, `backend/internal/db/db_test.go`, `collab-service/src/persistence/document-persistence.test.ts`. - [ ] Design Citus distributed tables, reference tables, and colocated table groups. - [ ] Review unique constraints, foreign keys, and cross-tenant joins. - [ ] Add `workspace_id` to worker payloads.