'boolean', 'published_at' => 'datetime', 'scheduled_for' => 'datetime', 'submitted_for_review_at' => 'datetime', 'reviewed_at' => 'datetime', 'views' => 'integer', 'likes_count' => 'integer', 'comments_count' => 'integer', 'reading_time' => 'integer', ]; // ── Relations ──────────────────────────────────────────────────────── public function creator(): BelongsTo { return $this->belongsTo(User::class, 'creator_id'); } // Legacy alias used by older views/controllers. public function author(): BelongsTo { return $this->creator(); } public function tags(): BelongsToMany { return $this->belongsToMany(StoryTag::class, 'relation_story_tags', 'story_id', 'tag_id'); } public function storyViews(): HasMany { return $this->hasMany(StoryView::class, 'story_id'); } public function storyLikes(): HasMany { return $this->hasMany(StoryLike::class, 'story_id'); } public function comments(): HasMany { return $this->hasMany(StoryComment::class, 'story_id'); } public function bookmarks(): HasMany { return $this->hasMany(StoryBookmark::class, 'story_id'); } // ── Scopes ─────────────────────────────────────────────────────────── public function scopePublished($query) { return $query ->where(function ($q): void { $q->where('status', 'published') ->orWhere(function ($scheduled): void { $scheduled->where('status', 'scheduled') ->whereNotNull('published_at') ->where('published_at', '<=', now()); }); }) ->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now())); } public function scopeFeatured($query) { return $query->where('featured', true); } // ── Accessors ──────────────────────────────────────────────────────── public function getUrlAttribute(): string { return url('/stories/' . $this->slug); } public function getCoverUrlAttribute(): ?string { return $this->resolveStoryMediaUrl($this->cover_image); } public function getOgImageUrlAttribute(): ?string { return $this->resolveStoryMediaUrl($this->og_image); } /** * Estimated reading time in minutes based on word count. */ public function getReadingTimeAttribute(): int { if (! empty($this->attributes['reading_time'])) { return max(1, (int) $this->attributes['reading_time']); } $wordCount = str_word_count(strip_tags((string) $this->content)); return max(1, (int) ceil($wordCount / 200)); } /** * Short excerpt for meta descriptions / cards. * Strips HTML, truncates to ~160 characters. */ public function getMetaExcerptAttribute(): string { $text = $this->excerpt ?: strip_tags((string) $this->content); return \Illuminate\Support\Str::limit($text, 160); } private function resolveStoryMediaUrl(?string $value): ?string { if (! $value) { return null; } if (str_starts_with($value, 'http')) { return $value; } $path = ltrim($value, '/'); if (str_starts_with($path, 'storage/')) { $path = substr($path, strlen('storage/')); } if (preg_match('#^stories/(sm|md|original)/[a-f0-9]{2}/[a-f0-9]{2}/[a-f0-9]+\.(webp|jpg|jpeg|png)$#', $path) === 1) { $cdnBase = rtrim((string) config('cdn.files_url', ''), '/'); return $cdnBase !== '' ? $cdnBase . '/' . $path : Storage::disk('public')->url($path); } return asset($value); } }