'boolean', 'display_order' => 'integer', 'view_count' => 'integer', 'meta_keywords' => 'array', 'published_at' => 'datetime', 'expires_at' => 'datetime', 'archived_at' => 'datetime', ]; // ==================== Boot ==================== protected static function boot() { parent::boot(); static::creating(function (Article $article) { if (empty($article->slug)) { $article->slug = static::generateUniqueSlug($article->title); } }); } public static function generateUniqueSlug(string $title): string { $slug = Str::slug($title); // For Chinese titles, slug may be empty if (empty($slug)) { $slug = Str::slug(Str::ascii($title)); } if (empty($slug)) { $slug = 'article-'.time(); } $originalSlug = $slug; $count = 1; while (static::withTrashed()->where('slug', $slug)->exists()) { $slug = $originalSlug.'-'.$count++; } return $slug; } // ==================== Relationships ==================== public function categories() { return $this->belongsToMany(ArticleCategory::class, 'article_category', 'article_id', 'category_id'); } public function tags() { return $this->belongsToMany(ArticleTag::class, 'article_tag', 'article_id', 'tag_id'); } public function attachments() { return $this->hasMany(ArticleAttachment::class); } public function creator() { return $this->belongsTo(User::class, 'created_by_user_id'); } public function lastUpdatedBy() { return $this->belongsTo(User::class, 'last_updated_by_user_id'); } public function authorUser() { return $this->belongsTo(User::class, 'author_user_id'); } // ==================== Status Check Methods ==================== public function isDraft(): bool { return $this->status === self::STATUS_DRAFT; } public function isPublished(): bool { return $this->status === self::STATUS_PUBLISHED; } public function isArchived(): bool { return $this->status === self::STATUS_ARCHIVED; } public function isPinned(): bool { return $this->is_pinned; } public function isExpired(): bool { if (! $this->expires_at) { return false; } return $this->expires_at->isPast(); } public function isScheduled(): bool { if (! $this->published_at) { return false; } return $this->published_at->isFuture(); } public function isActive(): bool { return $this->isPublished() && ! $this->isExpired() && (! $this->published_at || $this->published_at->isPast()); } // ==================== Access Control Methods ==================== public function canBeViewedBy(?User $user): bool { if ($this->isDraft()) { if (! $user) { return false; } return $user->id === $this->created_by_user_id || $user->hasRole('admin') || $user->can('manage_all_articles'); } if ($this->isArchived()) { if (! $user) { return false; } return $user->hasRole('admin') || $user->can('manage_all_articles'); } if ($this->isExpired()) { if (! $user) { return false; } return $user->hasRole('admin') || $user->can('manage_all_articles'); } if ($this->isScheduled()) { if (! $user) { return false; } return $user->id === $this->created_by_user_id || $user->hasRole('admin') || $user->can('manage_all_articles'); } if ($this->access_level === self::ACCESS_LEVEL_PUBLIC) { return true; } if (! $user) { return false; } if ($user->hasRole('admin')) { return true; } if ($this->access_level === self::ACCESS_LEVEL_MEMBERS) { return $user->member && $user->member->hasPaidMembership(); } if ($this->access_level === self::ACCESS_LEVEL_BOARD) { return $user->hasRole(['admin', 'finance_chair', 'finance_board_member']); } if ($this->access_level === self::ACCESS_LEVEL_ADMIN) { return $user->hasRole('admin'); } return false; } public function canBeEditedBy(User $user): bool { if ($user->hasRole('admin') || $user->can('manage_all_articles')) { return true; } if (! $user->can('edit_articles')) { return false; } return $user->id === $this->created_by_user_id; } // ==================== Query Scopes ==================== public function scopePublished(Builder $query): Builder { return $query->where('status', self::STATUS_PUBLISHED); } public function scopeDraft(Builder $query): Builder { return $query->where('status', self::STATUS_DRAFT); } public function scopeArchived(Builder $query): Builder { return $query->where('status', self::STATUS_ARCHIVED); } public function scopeActive(Builder $query): Builder { return $query->where('status', self::STATUS_PUBLISHED) ->where(function ($q) { $q->whereNull('published_at') ->orWhere('published_at', '<=', now()); }) ->where(function ($q) { $q->whereNull('expires_at') ->orWhere('expires_at', '>', now()); }); } public function scopeByContentType(Builder $query, string $contentType): Builder { return $query->where('content_type', $contentType); } public function scopePinned(Builder $query): Builder { return $query->where('is_pinned', true); } public function scopeForAccessLevel(Builder $query, ?User $user = null): Builder { if ($user && $user->hasRole('admin')) { return $query; } $accessLevels = [self::ACCESS_LEVEL_PUBLIC]; if ($user) { if ($user->member && $user->member->hasPaidMembership()) { $accessLevels[] = self::ACCESS_LEVEL_MEMBERS; } if ($user->hasRole(['finance_chair', 'finance_board_member'])) { $accessLevels[] = self::ACCESS_LEVEL_BOARD; } } return $query->whereIn('access_level', $accessLevels); } // ==================== Helper Methods ==================== public function publish(?User $user = null): void { $updates = [ 'status' => self::STATUS_PUBLISHED, ]; if (! $this->published_at) { $updates['published_at'] = now(); } if ($user) { $updates['last_updated_by_user_id'] = $user->id; } $this->update($updates); } public function archive(?User $user = null): void { $updates = [ 'status' => self::STATUS_ARCHIVED, 'archived_at' => now(), ]; if ($user) { $updates['last_updated_by_user_id'] = $user->id; } $this->update($updates); } public function pin(?int $order = null, ?User $user = null): void { $updates = [ 'is_pinned' => true, 'display_order' => $order ?? 0, ]; if ($user) { $updates['last_updated_by_user_id'] = $user->id; } $this->update($updates); } public function unpin(?User $user = null): void { $updates = [ 'is_pinned' => false, 'display_order' => 0, ]; if ($user) { $updates['last_updated_by_user_id'] = $user->id; } $this->update($updates); } public function incrementViewCount(): void { $this->increment('view_count'); } public function getAccessLevelLabel(): string { return match ($this->access_level) { self::ACCESS_LEVEL_PUBLIC => '公開', self::ACCESS_LEVEL_MEMBERS => '會員', self::ACCESS_LEVEL_BOARD => '理事會', self::ACCESS_LEVEL_ADMIN => '管理員', default => '未知', }; } public function getStatusLabel(): string { return match ($this->status) { self::STATUS_DRAFT => '草稿', self::STATUS_PUBLISHED => '已發布', self::STATUS_ARCHIVED => '已歸檔', default => '未知', }; } public function getStatusBadgeColor(): string { return match ($this->status) { self::STATUS_DRAFT => 'gray', self::STATUS_PUBLISHED => 'green', self::STATUS_ARCHIVED => 'yellow', default => 'gray', }; } public function getContentTypeLabel(): string { return match ($this->content_type) { self::CONTENT_TYPE_BLOG => '部落格', self::CONTENT_TYPE_NOTICE => '公告', self::CONTENT_TYPE_DOCUMENT => '文件', self::CONTENT_TYPE_RELATED_NEWS => '相關新聞', default => '未知', }; } public function getExcerpt(int $length = 150): string { $plainText = strip_tags($this->summary ?: $this->content); return Str::limit($plainText, $length); } public function getFeaturedImageUrlAttribute(): ?string { if (! $this->featured_image_path) { return null; } // Return relative path for Next.js frontend (images served from public/) return '/'.$this->featured_image_path; } }