Files
usher-manage-stack/app/Models/Article.php
gbanyan dd7077f77b Return relative image paths from API for Next.js frontend
Changed getFeaturedImageUrlAttribute to return relative paths
(e.g., /images/blog/photo.jpg) instead of full Laravel storage URLs.
Migrated image paths updated from migrated-images/ to images/ prefix.
Images are now served from Next.js public/ directory via Vercel CDN.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 16:32:18 +08:00

454 lines
11 KiB
PHP

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Article extends Model
{
use HasFactory, SoftDeletes;
// ==================== Constants ====================
const STATUS_DRAFT = 'draft';
const STATUS_PUBLISHED = 'published';
const STATUS_ARCHIVED = 'archived';
const ACCESS_LEVEL_PUBLIC = 'public';
const ACCESS_LEVEL_MEMBERS = 'members';
const ACCESS_LEVEL_BOARD = 'board';
const ACCESS_LEVEL_ADMIN = 'admin';
const CONTENT_TYPE_BLOG = 'blog';
const CONTENT_TYPE_NOTICE = 'notice';
const CONTENT_TYPE_DOCUMENT = 'document';
const CONTENT_TYPE_RELATED_NEWS = 'related_news';
// ==================== Configuration ====================
protected $fillable = [
'title',
'slug',
'summary',
'content',
'content_type',
'status',
'access_level',
'featured_image_path',
'featured_image_alt',
'author_name',
'author_user_id',
'meta_description',
'meta_keywords',
'is_pinned',
'display_order',
'published_at',
'expires_at',
'archived_at',
'view_count',
'created_by_user_id',
'last_updated_by_user_id',
];
protected $casts = [
'is_pinned' => '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;
}
}