ContentType::class, 'content_json' => 'array', 'editor_state' => 'array', 'seo_meta' => 'array', 'cdn_urls' => 'array', 'wp_created_at' => 'datetime', 'wp_modified_at' => 'datetime', 'publish_at' => 'datetime', 'synced_at' => 'datetime', 'cdn_purged_at' => 'datetime', 'preview_expires_at' => 'datetime', 'revision_count' => 'integer', ]; /** * Get the workspace this content belongs to. */ public function workspace(): BelongsTo { return $this->belongsTo(Workspace::class); } /** * Get the author of this content. */ public function author(): BelongsTo { return $this->belongsTo(ContentAuthor::class, 'author_id'); } /** * Get the user who last edited this content. */ public function lastEditedBy(): BelongsTo { return $this->belongsTo(User::class, 'last_edited_by'); } /** * Get the revision history for this content. */ public function revisions(): HasMany { return $this->hasMany(ContentRevision::class)->orderByDesc('revision_number'); } /** * Get the featured media for this content. */ public function featuredMedia(): BelongsTo { return $this->belongsTo(ContentMedia::class, 'featured_media_id', 'wp_id') ->where('workspace_id', $this->workspace_id); } /** * Get the taxonomies (categories and tags) for this content. */ public function taxonomies(): BelongsToMany { return $this->belongsToMany(ContentTaxonomy::class, 'content_item_taxonomy') ->withTimestamps(); } /** * Get only categories. */ public function categories(): BelongsToMany { return $this->taxonomies()->where('type', 'category'); } /** * Get only tags. */ public function tags(): BelongsToMany { return $this->taxonomies()->where('type', 'tag'); } /** * Scope to filter by workspace. */ public function scopeForWorkspace($query, int $workspaceId) { return $query->where('workspace_id', $workspaceId); } /** * Scope to only published content. */ public function scopePublished($query) { return $query->where('status', 'publish'); } /** * Scope to only posts. */ public function scopePosts($query) { return $query->where('type', 'post'); } /** * Scope to only pages. */ public function scopePages($query) { return $query->where('type', 'page'); } /** * Scope to items needing sync. */ public function scopeNeedsSync($query) { return $query->whereIn('sync_status', ['pending', 'failed', 'stale']); } /** * Scope to find by slug. */ public function scopeBySlug($query, string $slug) { return $query->where('slug', $slug); } /** * Scope to filter by slug prefix (e.g., 'help/' for help articles). */ public function scopeWithSlugPrefix($query, string $prefix) { return $query->where('slug', 'like', $prefix.'%'); } /** * Scope to help articles (pages with 'help' category or 'help/' slug prefix). */ public function scopeHelpArticles($query) { return $query->where(function ($q) { // Match pages with 'help/' slug prefix $q->where('slug', 'like', 'help/%') // Or pages in a 'help' category ->orWhereHas('categories', function ($catQuery) { $catQuery->where('slug', 'help') ->orWhere('slug', 'help-articles') ->orWhere('name', 'like', '%help%'); }); }); } /** * Scope to filter by content type. */ public function scopeOfContentType($query, ContentType|string $contentType) { $value = $contentType instanceof ContentType ? $contentType->value : $contentType; return $query->where('content_type', $value); } /** * Scope to only WordPress content (legacy). */ public function scopeWordpress($query) { return $query->where('content_type', ContentType::WORDPRESS->value); } /** * Scope to only native Host UK content. */ public function scopeHostuk($query) { return $query->where('content_type', ContentType::HOSTUK->value); } /** * Scope to only satellite content. */ public function scopeSatellite($query) { return $query->where('content_type', ContentType::SATELLITE->value); } /** * Scope to only native content (non-WordPress). * Includes: native, hostuk, satellite */ public function scopeNative($query) { return $query->whereIn('content_type', ContentType::nativeTypeValues()); } /** * Scope to only strictly native content (new default type). */ public function scopeStrictlyNative($query) { return $query->where('content_type', ContentType::NATIVE->value); } /** * Check if this is WordPress content (legacy). */ public function isWordpress(): bool { return $this->content_type === ContentType::WORDPRESS; } /** * Check if this is native Host UK content. */ public function isHostuk(): bool { return $this->content_type === ContentType::HOSTUK; } /** * Check if this is satellite content. */ public function isSatellite(): bool { return $this->content_type === ContentType::SATELLITE; } /** * Check if this is strictly native content (new default type). */ public function isNative(): bool { return $this->content_type === ContentType::NATIVE; } /** * Check if this is any native content type (non-WordPress). */ public function isAnyNative(): bool { return $this->content_type?->isNative() ?? false; } /** * Check if this content uses the Flux editor (non-WordPress). */ public function usesFluxEditor(): bool { return $this->content_type?->usesFluxEditor() ?? false; } /** * Get the display content (prefers clean HTML, falls back to markdown). */ public function getDisplayContentAttribute(): string { if ($this->usesFluxEditor()) { return $this->content_html ?? $this->content_markdown ?? ''; } return $this->content_html_clean ?? $this->content_html_original ?? ''; } /** * Get sanitised HTML content for safe rendering. * * Uses HTMLPurifier to remove XSS vectors while preserving * safe HTML elements like paragraphs, headings, lists, etc. */ public function getSanitisedContent(): string { $content = $this->display_content; if (empty($content)) { return ''; } // Use the StaticPageSanitiser if available if (class_exists(\Mod\Bio\Services\StaticPageSanitiser::class)) { return app(\Mod\Bio\Services\StaticPageSanitiser::class)->sanitiseHtml($content); } // Fallback: basic sanitisation using strip_tags with allowed tags $allowedTags = '