Compare commits

...

8 Commits

57 changed files with 2381 additions and 85 deletions

View File

@@ -57,3 +57,19 @@ VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}" VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}" VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
# Next.js site revalidation
NEXTJS_REVALIDATE_URL=
NEXTJS_REVALIDATE_TOKEN=
# Optional: sync uploaded images into Next.js public/ for static serving
# Example (local): /Users/gbanyan/Project/usher-site/public
NEXTJS_PUBLIC_PATH=
NEXTJS_PUBLIC_UPLOAD_PREFIX=uploads
# Optional: auto-commit+push assets in the Next.js repo on upload/import
NEXTJS_AUTOPUSH_ASSETS=false
NEXTJS_REPO_PATH=
NEXTJS_REPO_REMOTE=origin
NEXTJS_REPO_BRANCH=main
NEXTJS_REPO_ASSETS_COMMIT_MESSAGE="chore(assets): sync public assets"

2
.gitignore vendored
View File

@@ -16,6 +16,8 @@ Homestead.yaml
auth.json auth.json
npm-debug.log npm-debug.log
yarn-error.log yarn-error.log
.DS_Store
**/.DS_Store
/.fleet /.fleet
/.idea /.idea
/.vscode /.vscode

View File

@@ -0,0 +1,404 @@
<?php
namespace App\Console\Commands;
use App\Models\Article;
use App\Models\ArticleAttachment;
use App\Models\AuditLog;
use App\Models\Document;
use App\Models\DocumentCategory;
use App\Models\DocumentVersion;
use App\Models\User;
use App\Services\SiteRevalidationService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Ramsey\Uuid\Uuid;
class ImportArticleDocuments extends Command
{
protected $signature = 'articles:import-documents
{--category-slug= : Fallback document category slug}
{--fallback-user-id=1 : Fallback user ID when source article has no valid creator}
{--include-unpublished : Also import draft/archived article documents}
{--mark-archived : Mark source articles as archived after successful import}
{--limit=0 : Limit number of articles to import (0 means no limit)}
{--dry-run : Preview actions without writing data}';
protected $description = 'Import legacy article documents into document library for unified CRUD management';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$includeUnpublished = (bool) $this->option('include-unpublished');
$markArchived = (bool) $this->option('mark-archived');
$limit = max((int) $this->option('limit'), 0);
$fallbackUser = User::find((int) $this->option('fallback-user-id'));
if (! $fallbackUser) {
$this->error('Fallback user not found.');
return self::FAILURE;
}
$fallbackCategory = $this->resolveFallbackCategory($this->option('category-slug'));
if (! $fallbackCategory) {
$this->error('No document category found. Please create one or pass --category-slug.');
return self::FAILURE;
}
$query = Article::query()
->with(['attachments', 'categories', 'creator'])
->where('content_type', Article::CONTENT_TYPE_DOCUMENT);
if (! $includeUnpublished) {
$query->where('status', Article::STATUS_PUBLISHED);
}
if ($limit > 0) {
$query->limit($limit);
}
$articles = $query->orderBy('id')->get();
if ($articles->isEmpty()) {
$this->info('No article documents matched the import criteria.');
return self::SUCCESS;
}
$this->info('Article document import started');
$this->line('Matched articles: '.$articles->count());
$this->line('Fallback category: '.$fallbackCategory->slug);
$this->line('Dry run: '.($dryRun ? 'yes' : 'no'));
$this->line('Mark source archived: '.($markArchived ? 'yes' : 'no'));
$this->newLine();
$imported = 0;
$skipped = 0;
$failed = 0;
$archivedSources = 0;
foreach ($articles as $article) {
try {
$result = $this->importSingleArticle(
article: $article,
fallbackCategory: $fallbackCategory,
fallbackUser: $fallbackUser,
dryRun: $dryRun,
markArchived: $markArchived
);
if ($result === 'imported') {
$imported++;
} elseif ($result === 'archived_source') {
$imported++;
$archivedSources++;
} else {
$skipped++;
}
} catch (\Throwable $e) {
$failed++;
$this->error("{$article->slug}: {$e->getMessage()}");
}
}
$this->newLine();
$this->info('Import finished');
$this->line("Imported: {$imported}");
$this->line("Skipped: {$skipped}");
$this->line("Failed: {$failed}");
if ($markArchived) {
$this->line("Source articles archived: {$archivedSources}");
}
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
private function importSingleArticle(
Article $article,
DocumentCategory $fallbackCategory,
User $fallbackUser,
bool $dryRun,
bool $markArchived
): string {
$publicUuid = $this->deterministicPublicUuid($article);
$existing = Document::where('public_uuid', $publicUuid)->first();
if ($existing) {
if ($markArchived && $article->status !== Article::STATUS_ARCHIVED) {
$article->update([
'status' => Article::STATUS_ARCHIVED,
'archived_at' => now(),
'last_updated_by_user_id' => ($article->creator ?: $fallbackUser)->id,
]);
SiteRevalidationService::revalidateArticle($article->slug);
$this->info("{$article->slug}: already imported, source article archived.");
return 'archived_source';
}
$this->warn("{$article->slug}: already imported as document #{$existing->id}, skipping.");
return 'skipped';
}
$category = $this->resolveCategoryForArticle($article, $fallbackCategory);
$actor = $article->creator ?: $fallbackUser;
$accessLevel = $this->normalizeAccessLevel($article->access_level);
$documentStatus = $article->status === Article::STATUS_PUBLISHED ? 'active' : 'archived';
$description = $this->resolveDescription($article);
if ($dryRun) {
$this->line("{$article->slug}: would import to category={$category->slug}, access={$accessLevel}, attachments={$article->attachments->count()}");
return 'skipped';
}
DB::transaction(function () use (
$article,
$publicUuid,
$category,
$actor,
$accessLevel,
$documentStatus,
$description,
$markArchived
): void {
$document = Document::create([
'document_category_id' => $category->id,
'title' => $article->title,
'document_number' => null,
'description' => $description,
'public_uuid' => $publicUuid,
'access_level' => $accessLevel,
'status' => $documentStatus,
'created_by_user_id' => $actor->id,
'last_updated_by_user_id' => $actor->id,
'version_count' => 0,
'archived_at' => $documentStatus === 'archived' ? now() : null,
]);
$versionIds = [];
$orderedAttachments = $article->attachments->sortBy([
fn (ArticleAttachment $attachment) => $attachment->created_at?->timestamp ?? 0,
fn (ArticleAttachment $attachment) => $attachment->id,
])->values();
foreach ($orderedAttachments as $attachment) {
$copied = $this->copyAttachmentToPrivate($article, $attachment);
if (! $copied) {
continue;
}
$version = DocumentVersion::create([
'document_id' => $document->id,
'version_number' => $this->versionNumberFromIndex(count($versionIds)),
'version_notes' => $attachment->description ?: 'Imported from legacy article attachment',
'is_current' => false,
'file_path' => $copied['file_path'],
'original_filename' => $copied['original_filename'],
'mime_type' => $copied['mime_type'],
'file_size' => $copied['file_size'],
'file_hash' => $copied['file_hash'],
'uploaded_by_user_id' => $actor->id,
'uploaded_at' => $attachment->created_at ?? $article->updated_at ?? now(),
'created_at' => $attachment->created_at ?? $article->updated_at ?? now(),
'updated_at' => $attachment->updated_at ?? $attachment->created_at ?? now(),
]);
$versionIds[] = $version->id;
}
if ($versionIds === []) {
$markdownVersion = $this->createMarkdownVersion($article, $document, $actor);
$versionIds[] = $markdownVersion->id;
}
$currentVersionId = end($versionIds) ?: null;
if (! $currentVersionId) {
throw new \RuntimeException("Failed to create version for article {$article->slug}");
}
DocumentVersion::where('document_id', $document->id)->update(['is_current' => false]);
DocumentVersion::where('id', $currentVersionId)->update(['is_current' => true]);
$document->forceFill([
'current_version_id' => $currentVersionId,
'version_count' => count($versionIds),
'created_at' => $article->published_at ?? $article->created_at ?? now(),
'updated_at' => $article->updated_at ?? $article->published_at ?? now(),
])->save();
if ($markArchived && $article->status !== Article::STATUS_ARCHIVED) {
$article->update([
'status' => Article::STATUS_ARCHIVED,
'archived_at' => now(),
'last_updated_by_user_id' => $actor->id,
]);
SiteRevalidationService::revalidateArticle($article->slug);
}
AuditLog::create([
'user_id' => $actor->id,
'action' => 'article.document_imported',
'description' => "Imported article document {$article->slug} -> document #{$document->id}",
'auditable_type' => Document::class,
'auditable_id' => $document->id,
'metadata' => [
'source_article_id' => $article->id,
'source_article_slug' => $article->slug,
'imported_version_count' => count($versionIds),
],
'ip_address' => '127.0.0.1',
]);
});
$this->info("{$article->slug}: imported.");
return $markArchived ? 'archived_source' : 'imported';
}
private function resolveFallbackCategory(?string $categorySlug): ?DocumentCategory
{
if ($categorySlug) {
return DocumentCategory::where('slug', $categorySlug)->first();
}
return DocumentCategory::where('slug', 'organization-public-disclosure')->first()
?: DocumentCategory::orderBy('sort_order')->orderBy('id')->first();
}
private function resolveCategoryForArticle(Article $article, DocumentCategory $fallbackCategory): DocumentCategory
{
foreach ($article->categories as $category) {
$matched = DocumentCategory::where('slug', $category->slug)->first();
if ($matched) {
return $matched;
}
}
return $fallbackCategory;
}
private function deterministicPublicUuid(Article $article): string
{
return Uuid::uuid5(Uuid::NAMESPACE_URL, "legacy-article-document:{$article->id}:{$article->slug}")->toString();
}
private function resolveDescription(Article $article): ?string
{
$description = $article->summary ?: $article->meta_description;
if ($description) {
return $description;
}
$plain = trim(strip_tags($article->content));
if ($plain === '') {
return null;
}
return Str::limit($plain, 400);
}
private function normalizeAccessLevel(?string $accessLevel): string
{
return in_array($accessLevel, ['public', 'members', 'admin', 'board'], true)
? $accessLevel
: 'members';
}
private function versionNumberFromIndex(int $index): string
{
if ($index === 0) {
return '1.0';
}
return '1.'.$index;
}
/**
* @return array{file_path:string, original_filename:string, mime_type:string, file_size:int, file_hash:string}|null
*/
private function copyAttachmentToPrivate(Article $article, ArticleAttachment $attachment): ?array
{
if (! Storage::disk('public')->exists($attachment->file_path)) {
$this->warn(" - {$article->slug}: attachment #{$attachment->id} file missing ({$attachment->file_path}), skipped.");
return null;
}
$bytes = Storage::disk('public')->get($attachment->file_path);
$extension = pathinfo($attachment->original_filename, PATHINFO_EXTENSION);
$safeName = $this->sanitizeFilename(pathinfo($attachment->original_filename, PATHINFO_FILENAME) ?: 'attachment');
$targetPath = 'documents/imported/articles/'.$article->id.'/'.Str::uuid().'-'.$safeName.($extension ? '.'.$extension : '');
Storage::disk('private')->put($targetPath, $bytes);
return [
'file_path' => $targetPath,
'original_filename' => $attachment->original_filename,
'mime_type' => $attachment->mime_type ?: 'application/octet-stream',
'file_size' => strlen($bytes),
'file_hash' => hash('sha256', $bytes),
];
}
private function createMarkdownVersion(Article $article, Document $document, User $actor): DocumentVersion
{
$content = $this->buildMarkdownFromArticle($article);
$targetPath = 'documents/imported/articles/'.$article->id.'/'.Str::uuid().'-'.$article->slug.'.md';
Storage::disk('private')->put($targetPath, $content);
return DocumentVersion::create([
'document_id' => $document->id,
'version_number' => '1.0',
'version_notes' => 'Imported from legacy article content (no file attachments)',
'is_current' => false,
'file_path' => $targetPath,
'original_filename' => $article->slug.'.md',
'mime_type' => 'text/markdown',
'file_size' => strlen($content),
'file_hash' => hash('sha256', $content),
'uploaded_by_user_id' => $actor->id,
'uploaded_at' => $article->updated_at ?? $article->published_at ?? now(),
'created_at' => $article->created_at ?? now(),
'updated_at' => $article->updated_at ?? $article->created_at ?? now(),
]);
}
private function buildMarkdownFromArticle(Article $article): string
{
$sections = [
'# '.$article->title,
'',
'- Source article slug: `'.$article->slug.'`',
'- Source article id: `'.$article->id.'`',
'- Imported at: `'.now()->toIso8601String().'`',
'',
];
if ($article->summary) {
$sections[] = '## Summary';
$sections[] = '';
$sections[] = trim($article->summary);
$sections[] = '';
}
$sections[] = '## Content';
$sections[] = '';
$sections[] = trim($article->content);
$sections[] = '';
return implode("\n", $sections);
}
private function sanitizeFilename(string $name): string
{
$sanitized = preg_replace('/[^A-Za-z0-9._-]+/', '-', $name) ?: 'file';
return trim($sanitized, '-');
}
}

View File

@@ -30,7 +30,7 @@ class ImportHugoContent extends Command
private int $tagsCreated = 0; private int $tagsCreated = 0;
private int $imagescopied = 0; private int $imagesCopied = 0;
private int $skipped = 0; private int $skipped = 0;
@@ -140,7 +140,7 @@ class ImportHugoContent extends Command
['Pages created', $this->pagesCreated], ['Pages created', $this->pagesCreated],
['Categories created', $this->categoriesCreated], ['Categories created', $this->categoriesCreated],
['Tags created', $this->tagsCreated], ['Tags created', $this->tagsCreated],
['Images copied', $this->imagescopied], ['Images copied', $this->imagesCopied],
['Skipped (_index.md etc.)', $this->skipped], ['Skipped (_index.md etc.)', $this->skipped],
] ]
); );
@@ -360,7 +360,15 @@ class ImportHugoContent extends Command
{ {
$this->info("Copying images from: {$imagesPath}"); $this->info("Copying images from: {$imagesPath}");
$destPath = storage_path('app/public/migrated-images'); $nextPublicRoot = config('services.nextjs.public_path');
if (! is_string($nextPublicRoot) || $nextPublicRoot === '' || ! is_dir($nextPublicRoot)) {
$nextPublicRoot = base_path('../usher-site/public');
}
$nextPublicImages = rtrim($nextPublicRoot, '/').'/images';
$destPath = is_dir($nextPublicImages)
? $nextPublicImages
: storage_path('app/public/migrated-images');
if (! $isDryRun && ! is_dir($destPath)) { if (! $isDryRun && ! is_dir($destPath)) {
File::makeDirectory($destPath, 0755, true); File::makeDirectory($destPath, 0755, true);
@@ -382,7 +390,12 @@ class ImportHugoContent extends Command
File::copy($file->getPathname(), $destFile); File::copy($file->getPathname(), $destFile);
} }
$this->imagescopied++; $this->imagesCopied++;
}
// If we copied into the Next.js repo, optionally auto-push the new assets.
if (str_ends_with($destPath, '/images') && is_dir(dirname($destPath).'/.git')) {
\App\Services\NextjsRepoSyncService::scheduleAssetsPush();
} }
} }
@@ -511,7 +524,7 @@ class ImportHugoContent extends Command
/** /**
* Convert Hugo image paths to migrated storage paths. * Convert Hugo image paths to migrated storage paths.
* Hugo: "images/blog/Update.jpg" "migrated-images/blog/Update.jpg" * Hugo: "images/blog/Update.jpg" "images/blog/Update.jpg"
*/ */
private function resolveImagePath(?string $hugoPath): ?string private function resolveImagePath(?string $hugoPath): ?string
{ {
@@ -519,9 +532,13 @@ class ImportHugoContent extends Command
return null; return null;
} }
// Remove leading "images/" prefix $path = ltrim($hugoPath, '/');
$relativePath = preg_replace('#^images/#', '', $hugoPath);
return "migrated-images/{$relativePath}"; // Normalize to "images/..." so it can be served from Next.js public/images/.
if (! str_starts_with($path, 'images/')) {
$path = 'images/'.ltrim($path, '/');
}
return $path;
} }
} }

View File

@@ -85,6 +85,7 @@ class ImportMembers extends Command
$memberNumber = isset($indexes['member_number']) ? trim($row[$indexes['member_number']] ?? '') : ''; $memberNumber = isset($indexes['member_number']) ? trim($row[$indexes['member_number']] ?? '') : '';
$nationalId = isset($indexes['national_id']) ? trim($row[$indexes['national_id']] ?? '') : ''; $nationalId = isset($indexes['national_id']) ? trim($row[$indexes['national_id']] ?? '') : '';
$phone = trim($row[$indexes['phone']] ?? ''); $phone = trim($row[$indexes['phone']] ?? '');
$lineId = isset($indexes['line_id']) ? trim($row[$indexes['line_id']] ?? '') : '';
$phoneHome = isset($indexes['phone_home']) ? trim($row[$indexes['phone_home']] ?? '') : ''; $phoneHome = isset($indexes['phone_home']) ? trim($row[$indexes['phone_home']] ?? '') : '';
$phoneFax = isset($indexes['phone_fax']) ? trim($row[$indexes['phone_fax']] ?? '') : ''; $phoneFax = isset($indexes['phone_fax']) ? trim($row[$indexes['phone_fax']] ?? '') : '';
$birthDate = isset($indexes['birth_date']) ? trim($row[$indexes['birth_date']] ?? '') : ''; $birthDate = isset($indexes['birth_date']) ? trim($row[$indexes['birth_date']] ?? '') : '';
@@ -125,6 +126,7 @@ class ImportMembers extends Command
'email' => $email, 'email' => $email,
'national_id' => $nationalId !== '' ? $nationalId : null, 'national_id' => $nationalId !== '' ? $nationalId : null,
'phone' => $phone !== '' ? $phone : null, 'phone' => $phone !== '' ? $phone : null,
'line_id' => $lineId !== '' ? $lineId : null,
'phone_home' => $phoneHome !== '' ? $phoneHome : null, 'phone_home' => $phoneHome !== '' ? $phoneHome : null,
'phone_fax' => $phoneFax !== '' ? $phoneFax : null, 'phone_fax' => $phoneFax !== '' ? $phoneFax : null,
'birth_date' => $birthDate !== '' ? $birthDate : null, 'birth_date' => $birthDate !== '' ? $birthDate : null,

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Console\Commands;
use App\Services\NextjsRepoSyncService;
use Illuminate\Console\Command;
class NextjsPushAssets extends Command
{
protected $signature = 'nextjs:push-assets';
protected $description = 'Stage/commit/push Next.js public asset changes (public/uploads and public/images)';
public function handle(): int
{
NextjsRepoSyncService::pushPublicAssets();
$this->info('Done (best-effort). Check logs if nothing happened.');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,174 @@
<?php
namespace App\Console\Commands;
use App\Models\Article;
use App\Models\ArticleCategory;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class ReclassifyDocumentContent extends Command
{
protected $signature = 'articles:reclassify-document-content {--dry-run : Preview changes without writing to database}';
protected $description = 'Reclassify document-type articles into real documents vs story/blog content';
/**
* Keep as document resources.
*
* @var array<int, string>
*/
private array $keepDocumentSlugs = [
'whisper-colab',
'document-ef6231ce',
'document-d34b35ab',
];
/**
* Move from document to blog/guides.
*
* @var array<int, string>
*/
private array $moveToGuidesSlugs = [
'document-b81734e3',
'document-25e793fb',
'document-48ec7f25',
];
/**
* Move from document to blog/story.
*
* @var array<int, string>
*/
private array $moveToStorySlugs = [
'document-2f8fad62',
'document-f68da0b8',
'document-a8bdf6d9',
'document-694e4dd6',
'document-d0608b86',
'document-163bc74d',
'document-ef370202',
];
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$allSlugs = array_values(array_unique(array_merge(
$this->keepDocumentSlugs,
$this->moveToGuidesSlugs,
$this->moveToStorySlugs
)));
$articles = Article::query()
->whereIn('slug', $allSlugs)
->get()
->keyBy('slug');
$missingSlugs = array_values(array_diff($allSlugs, $articles->keys()->all()));
$documentCategory = ArticleCategory::firstOrCreate(
['slug' => 'document'],
['name' => '協會文件', 'sort_order' => 20]
);
$storyCategory = ArticleCategory::firstOrCreate(
['slug' => 'story'],
['name' => '故事', 'sort_order' => 30]
);
$guidesCategory = ArticleCategory::firstOrCreate(
['slug' => 'guides'],
['name' => '建議與指引', 'sort_order' => 25]
);
$this->newLine();
$this->info('Reclassification plan');
$this->line(' Keep as document: '.count($this->keepDocumentSlugs));
$this->line(' Move to guides/blog: '.count($this->moveToGuidesSlugs));
$this->line(' Move to story/blog: '.count($this->moveToStorySlugs));
$this->line(' Found in DB: '.$articles->count());
if (! empty($missingSlugs)) {
$this->warn('Missing slugs in DB: '.implode(', ', $missingSlugs));
}
$this->newLine();
$this->table(
['Slug', 'Current Type', 'Target Type', 'Target Category'],
collect($allSlugs)->map(function (string $slug) use ($articles) {
$article = $articles->get($slug);
$targetIsDocument = in_array($slug, $this->keepDocumentSlugs, true);
$targetIsGuides = in_array($slug, $this->moveToGuidesSlugs, true);
return [
$slug,
$article?->content_type ?? '(missing)',
$targetIsDocument ? Article::CONTENT_TYPE_DOCUMENT : Article::CONTENT_TYPE_BLOG,
$targetIsDocument ? 'document' : ($targetIsGuides ? 'guides' : 'story'),
];
})->all()
);
if ($dryRun) {
$this->newLine();
$this->comment('Dry run only. No database updates were applied.');
return static::SUCCESS;
}
DB::transaction(function () use ($articles, $documentCategory, $guidesCategory, $storyCategory): void {
foreach ($this->keepDocumentSlugs as $slug) {
$article = $articles->get($slug);
if (! $article) {
continue;
}
$article->update(['content_type' => Article::CONTENT_TYPE_DOCUMENT]);
$article->categories()->sync([$documentCategory->id]);
}
foreach ($this->moveToGuidesSlugs as $slug) {
$article = $articles->get($slug);
if (! $article) {
continue;
}
$article->update(['content_type' => Article::CONTENT_TYPE_BLOG]);
$article->categories()->sync([$guidesCategory->id]);
}
foreach ($this->moveToStorySlugs as $slug) {
$article = $articles->get($slug);
if (! $article) {
continue;
}
$article->update(['content_type' => Article::CONTENT_TYPE_BLOG]);
$article->categories()->sync([$storyCategory->id]);
}
});
$revalidated = 0;
$revalidateServiceClass = '\\App\\Services\\SiteRevalidationService';
$canRevalidate = class_exists($revalidateServiceClass);
if (! $canRevalidate) {
$this->warn('SiteRevalidationService not found. Skipping cache revalidation.');
}
foreach ($articles as $article) {
if (! $article->isPublished() || ! $canRevalidate) {
continue;
}
$revalidateServiceClass::revalidateArticle($article->slug);
$revalidated++;
}
$this->newLine();
$this->info('Reclassification completed.');
$this->line(' Updated articles: '.$articles->count());
$this->line(' Revalidation requests sent: '.$revalidated);
return static::SUCCESS;
}
}

View File

@@ -8,6 +8,8 @@ use App\Models\ArticleAttachment;
use App\Models\ArticleCategory; use App\Models\ArticleCategory;
use App\Models\ArticleTag; use App\Models\ArticleTag;
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Services\SiteAssetSyncService;
use App\Services\SiteRevalidationService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -117,7 +119,15 @@ class ArticleController extends Controller
]; ];
if ($request->hasFile('featured_image')) { if ($request->hasFile('featured_image')) {
$articleData['featured_image_path'] = $request->file('featured_image')->store('articles/images', 'public'); $storagePath = $request->file('featured_image')->store('articles/images', 'public');
$nextPublicPath = SiteAssetSyncService::syncPublicDiskFileToNext($storagePath);
if ($nextPublicPath) {
$articleData['featured_image_path'] = $nextPublicPath;
$articleData['featured_image_storage_path'] = $storagePath;
} else {
$articleData['featured_image_path'] = $storagePath;
}
} }
$article = Article::create($articleData); $article = Article::create($articleData);
@@ -148,6 +158,10 @@ class ArticleController extends Controller
'ip_address' => request()->ip(), 'ip_address' => request()->ip(),
]); ]);
if ($validated['save_action'] === 'publish') {
SiteRevalidationService::revalidateArticle($article->slug);
}
$message = $validated['save_action'] === 'publish' ? '文章已成功發布' : '文章已儲存為草稿'; $message = $validated['save_action'] === 'publish' ? '文章已成功發布' : '文章已儲存為草稿';
return redirect() return redirect()
@@ -227,13 +241,35 @@ class ArticleController extends Controller
} }
if ($request->hasFile('featured_image')) { if ($request->hasFile('featured_image')) {
if ($article->featured_image_path) { // Remove old copies (best-effort). If we synced to Next.js, the DB path is relative (uploads/...).
if ($article->featured_image_storage_path) {
Storage::disk('public')->delete($article->featured_image_storage_path);
} elseif ($article->featured_image_path && str_starts_with($article->featured_image_path, 'articles/')) {
Storage::disk('public')->delete($article->featured_image_path); Storage::disk('public')->delete($article->featured_image_path);
} }
$updateData['featured_image_path'] = $request->file('featured_image')->store('articles/images', 'public');
SiteAssetSyncService::deleteNextPublicFile($article->featured_image_path);
$storagePath = $request->file('featured_image')->store('articles/images', 'public');
$nextPublicPath = SiteAssetSyncService::syncPublicDiskFileToNext($storagePath);
if ($nextPublicPath) {
$updateData['featured_image_path'] = $nextPublicPath;
$updateData['featured_image_storage_path'] = $storagePath;
} else {
$updateData['featured_image_path'] = $storagePath;
$updateData['featured_image_storage_path'] = null;
}
} elseif ($request->boolean('remove_featured_image') && $article->featured_image_path) { } elseif ($request->boolean('remove_featured_image') && $article->featured_image_path) {
Storage::disk('public')->delete($article->featured_image_path); if ($article->featured_image_storage_path) {
Storage::disk('public')->delete($article->featured_image_storage_path);
} elseif (str_starts_with($article->featured_image_path, 'articles/')) {
Storage::disk('public')->delete($article->featured_image_path);
}
SiteAssetSyncService::deleteNextPublicFile($article->featured_image_path);
$updateData['featured_image_path'] = null; $updateData['featured_image_path'] = null;
$updateData['featured_image_storage_path'] = null;
} }
$article->update($updateData); $article->update($updateData);
@@ -264,6 +300,10 @@ class ArticleController extends Controller
'ip_address' => request()->ip(), 'ip_address' => request()->ip(),
]); ]);
if ($article->isPublished()) {
SiteRevalidationService::revalidateArticle($article->slug);
}
return redirect() return redirect()
->route('admin.articles.show', $article) ->route('admin.articles.show', $article)
->with('status', '文章已成功更新'); ->with('status', '文章已成功更新');
@@ -276,6 +316,8 @@ class ArticleController extends Controller
} }
$title = $article->title; $title = $article->title;
$slug = $article->slug;
$wasPublished = $article->isPublished();
$article->delete(); $article->delete();
AuditLog::create([ AuditLog::create([
@@ -285,6 +327,10 @@ class ArticleController extends Controller
'ip_address' => request()->ip(), 'ip_address' => request()->ip(),
]); ]);
if ($wasPublished) {
SiteRevalidationService::revalidateArticle($slug);
}
return redirect() return redirect()
->route('admin.articles.index') ->route('admin.articles.index')
->with('status', '文章已成功刪除'); ->with('status', '文章已成功刪除');
@@ -313,6 +359,8 @@ class ArticleController extends Controller
'ip_address' => request()->ip(), 'ip_address' => request()->ip(),
]); ]);
SiteRevalidationService::revalidateArticle($article->slug);
return back()->with('status', '文章已成功發布'); return back()->with('status', '文章已成功發布');
} }
@@ -339,6 +387,8 @@ class ArticleController extends Controller
'ip_address' => request()->ip(), 'ip_address' => request()->ip(),
]); ]);
SiteRevalidationService::revalidateArticle($article->slug);
return back()->with('status', '文章已成功歸檔'); return back()->with('status', '文章已成功歸檔');
} }

View File

@@ -7,6 +7,7 @@ use App\Models\AuditLog;
use App\Models\Document; use App\Models\Document;
use App\Models\DocumentCategory; use App\Models\DocumentCategory;
use App\Models\DocumentVersion; use App\Models\DocumentVersion;
use App\Support\DownloadFile;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -238,9 +239,10 @@ class DocumentController extends Controller
// Log access // Log access
$document->logAccess('download', auth()->user()); $document->logAccess('download', auth()->user());
return Storage::disk('private')->download( return DownloadFile::fromDisk(
$version->file_path, disk: 'private',
$version->original_filename path: $version->file_path,
downloadName: $version->original_filename
); );
} }

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\Page; use App\Models\Page;
use App\Services\SiteRevalidationService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class PageController extends Controller class PageController extends Controller
@@ -72,6 +73,10 @@ class PageController extends Controller
'ip_address' => request()->ip(), 'ip_address' => request()->ip(),
]); ]);
if ($validated['status'] === 'published') {
SiteRevalidationService::revalidatePage($page->slug);
}
return redirect() return redirect()
->route('admin.pages.show', $page) ->route('admin.pages.show', $page)
->with('status', '頁面已成功建立'); ->with('status', '頁面已成功建立');
@@ -135,6 +140,10 @@ class PageController extends Controller
'ip_address' => request()->ip(), 'ip_address' => request()->ip(),
]); ]);
if ($page->isPublished()) {
SiteRevalidationService::revalidatePage($page->slug);
}
return redirect() return redirect()
->route('admin.pages.show', $page) ->route('admin.pages.show', $page)
->with('status', '頁面已成功更新'); ->with('status', '頁面已成功更新');
@@ -147,6 +156,8 @@ class PageController extends Controller
} }
$title = $page->title; $title = $page->title;
$slug = $page->slug;
$wasPublished = $page->isPublished();
$page->delete(); $page->delete();
AuditLog::create([ AuditLog::create([
@@ -156,6 +167,10 @@ class PageController extends Controller
'ip_address' => request()->ip(), 'ip_address' => request()->ip(),
]); ]);
if ($wasPublished) {
SiteRevalidationService::revalidatePage($slug);
}
return redirect() return redirect()
->route('admin.pages.index') ->route('admin.pages.index')
->with('status', '頁面已成功刪除'); ->with('status', '頁面已成功刪除');
@@ -176,6 +191,8 @@ class PageController extends Controller
'ip_address' => request()->ip(), 'ip_address' => request()->ip(),
]); ]);
SiteRevalidationService::revalidatePage($page->slug);
return back()->with('status', '頁面已成功發布'); return back()->with('status', '頁面已成功發布');
} }
} }

View File

@@ -17,12 +17,13 @@ class AdminMemberController extends Controller
{ {
$query = Member::query()->with('user'); $query = Member::query()->with('user');
// Text search (name, email, phone, national ID) // Text search (name, email, phone, Line ID, national ID)
if ($search = $request->string('search')->toString()) { if ($search = $request->string('search')->toString()) {
$query->where(function ($q) use ($search) { $query->where(function ($q) use ($search) {
$q->where('full_name', 'like', "%{$search}%") $q->where('full_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%") ->orWhere('email', 'like', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%"); ->orWhere('phone', 'like', "%{$search}%")
->orWhere('line_id', 'like', "%{$search}%");
// Search by national ID hash if provided // Search by national ID hash if provided
if (!empty($search)) { if (!empty($search)) {
@@ -256,7 +257,13 @@ class AdminMemberController extends Controller
if ($search = $request->string('search')->toString()) { if ($search = $request->string('search')->toString()) {
$query->where(function ($q) use ($search) { $query->where(function ($q) use ($search) {
$q->where('full_name', 'like', "%{$search}%") $q->where('full_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%"); ->orWhere('email', 'like', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%")
->orWhere('line_id', 'like', "%{$search}%");
if (!empty($search)) {
$q->orWhere('national_id_hash', hash('sha256', $search));
}
}); });
} }
@@ -276,6 +283,7 @@ class AdminMemberController extends Controller
'Full Name', 'Full Name',
'Email', 'Email',
'Phone', 'Phone',
'Line ID',
'Address Line 1', 'Address Line 1',
'Address Line 2', 'Address Line 2',
'City', 'City',
@@ -297,6 +305,7 @@ class AdminMemberController extends Controller
$member->full_name, $member->full_name,
$member->email, $member->email,
$member->phone, $member->phone,
$member->line_id,
$member->address_line_1, $member->address_line_1,
$member->address_line_2, $member->address_line_2,
$member->city, $member->city,

View File

@@ -7,8 +7,8 @@ use App\Http\Resources\ArticleCollectionResource;
use App\Http\Resources\ArticleResource; use App\Http\Resources\ArticleResource;
use App\Models\Article; use App\Models\Article;
use App\Models\ArticleAttachment; use App\Models\ArticleAttachment;
use App\Support\DownloadFile;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ArticleController extends Controller class ArticleController extends Controller
{ {
@@ -87,9 +87,10 @@ class ArticleController extends Controller
$attachment->incrementDownloadCount(); $attachment->incrementDownloadCount();
return Storage::disk('public')->download( return DownloadFile::fromDisk(
$attachment->file_path, disk: 'public',
$attachment->original_filename path: $attachment->file_path,
downloadName: $attachment->original_filename
); );
} }
} }

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\PublicDocumentCollectionResource;
use App\Http\Resources\PublicDocumentResource;
use App\Models\Document;
use Illuminate\Http\Request;
class PublicDocumentController extends Controller
{
/**
* List public documents for external site consumption.
*/
public function index(Request $request)
{
$query = Document::query()
->with(['category', 'currentVersion.uploadedBy'])
->where('status', 'active')
->where('access_level', 'public')
->whereNotNull('current_version_id')
->orderByDesc('updated_at');
if ($request->filled('search')) {
$search = trim((string) $request->input('search'));
$query->where(function ($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%")
->orWhere('document_number', 'like', "%{$search}%");
});
}
if ($request->filled('category')) {
$category = (string) $request->input('category');
if (ctype_digit($category)) {
$query->where('document_category_id', (int) $category);
} else {
$query->whereHas('category', function ($q) use ($category) {
$q->where('slug', $category);
});
}
}
$perPage = min(max($request->integer('per_page', 100), 1), 500);
return PublicDocumentCollectionResource::collection(
$query->paginate($perPage)->withQueryString()
);
}
/**
* Show a single public document with version history.
*/
public function show(string $uuid)
{
$document = Document::query()
->with([
'category',
'currentVersion.uploadedBy',
'versions.uploadedBy',
'versions.document',
'createdBy',
'lastUpdatedBy',
])
->where('public_uuid', $uuid)
->where('status', 'active')
->where('access_level', 'public')
->whereNotNull('current_version_id')
->firstOrFail();
$related = Document::query()
->with(['category', 'currentVersion.uploadedBy'])
->where('status', 'active')
->where('access_level', 'public')
->whereNotNull('current_version_id')
->where('id', '!=', $document->id)
->where('document_category_id', $document->document_category_id)
->orderByDesc('updated_at')
->limit(4)
->get();
return response()->json([
'data' => new PublicDocumentResource($document),
'related' => PublicDocumentCollectionResource::collection($related),
]);
}
}

View File

@@ -54,6 +54,7 @@ class MemberDashboardController extends Controller
$validated = $request->validate([ $validated = $request->validate([
'full_name' => ['required', 'string', 'max:255'], 'full_name' => ['required', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:20'], 'phone' => ['nullable', 'string', 'max:20'],
'line_id' => ['nullable', 'string', 'max:100'],
'national_id' => ['nullable', 'string', 'max:20'], 'national_id' => ['nullable', 'string', 'max:20'],
'address_line_1' => ['nullable', 'string', 'max:255'], 'address_line_1' => ['nullable', 'string', 'max:255'],
'address_line_2' => ['nullable', 'string', 'max:255'], 'address_line_2' => ['nullable', 'string', 'max:255'],
@@ -69,6 +70,7 @@ class MemberDashboardController extends Controller
'full_name' => $validated['full_name'], 'full_name' => $validated['full_name'],
'email' => $user->email, 'email' => $user->email,
'phone' => $validated['phone'] ?? null, 'phone' => $validated['phone'] ?? null,
'line_id' => $validated['line_id'] ?? null,
'national_id' => $validated['national_id'] ?? null, 'national_id' => $validated['national_id'] ?? null,
'address_line_1' => $validated['address_line_1'] ?? null, 'address_line_1' => $validated['address_line_1'] ?? null,
'address_line_2' => $validated['address_line_2'] ?? null, 'address_line_2' => $validated['address_line_2'] ?? null,
@@ -88,4 +90,4 @@ class MemberDashboardController extends Controller
return redirect()->route('member.dashboard') return redirect()->route('member.dashboard')
->with('status', __('Profile completed! Please submit your membership payment.')); ->with('status', __('Profile completed! Please submit your membership payment.'));
} }
} }

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Models\Document; use App\Models\Document;
use App\Models\DocumentCategory; use App\Models\DocumentCategory;
use App\Support\DownloadFile;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -109,9 +110,10 @@ class PublicDocumentController extends Controller
// Log access // Log access
$document->logAccess('download', $user); $document->logAccess('download', $user);
return Storage::disk('private')->download( return DownloadFile::fromDisk(
$currentVersion->file_path, disk: 'private',
$currentVersion->original_filename path: $currentVersion->file_path,
downloadName: $currentVersion->original_filename
); );
} }
@@ -139,9 +141,10 @@ class PublicDocumentController extends Controller
// Log access // Log access
$document->logAccess('download', $user); $document->logAccess('download', $user);
return Storage::disk('private')->download( return DownloadFile::fromDisk(
$version->file_path, disk: 'private',
$version->original_filename path: $version->file_path,
downloadName: $version->original_filename
); );
} }

View File

@@ -32,6 +32,7 @@ class PublicMemberRegistrationController extends Controller
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email', 'unique:members,email'], 'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email', 'unique:members,email'],
'password' => ['required', 'confirmed', Password::defaults()], 'password' => ['required', 'confirmed', Password::defaults()],
'phone' => ['nullable', 'string', 'max:20'], 'phone' => ['nullable', 'string', 'max:20'],
'line_id' => ['nullable', 'string', 'max:100'],
'national_id' => ['nullable', 'string', 'max:20'], 'national_id' => ['nullable', 'string', 'max:20'],
'address_line_1' => ['nullable', 'string', 'max:255'], 'address_line_1' => ['nullable', 'string', 'max:255'],
'address_line_2' => ['nullable', 'string', 'max:255'], 'address_line_2' => ['nullable', 'string', 'max:255'],
@@ -57,6 +58,7 @@ class PublicMemberRegistrationController extends Controller
'full_name' => $validated['full_name'], 'full_name' => $validated['full_name'],
'email' => $validated['email'], 'email' => $validated['email'],
'phone' => $validated['phone'] ?? null, 'phone' => $validated['phone'] ?? null,
'line_id' => $validated['line_id'] ?? null,
'national_id' => $validated['national_id'] ?? null, 'national_id' => $validated['national_id'] ?? null,
'address_line_1' => $validated['address_line_1'] ?? null, 'address_line_1' => $validated['address_line_1'] ?? null,
'address_line_2' => $validated['address_line_2'] ?? null, 'address_line_2' => $validated['address_line_2'] ?? null,

View File

@@ -27,6 +27,7 @@ class StoreMemberRequest extends FormRequest
'email' => ['required', 'email', 'max:255', 'unique:users,email'], 'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'national_id' => ['nullable', 'string', 'max:50'], 'national_id' => ['nullable', 'string', 'max:50'],
'phone' => ['nullable', 'string', 'max:50'], 'phone' => ['nullable', 'string', 'max:50'],
'line_id' => ['nullable', 'string', 'max:100'],
'phone_home' => ['nullable', 'string', 'max:50'], 'phone_home' => ['nullable', 'string', 'max:50'],
'phone_fax' => ['nullable', 'string', 'max:50'], 'phone_fax' => ['nullable', 'string', 'max:50'],
'birth_date' => ['nullable', 'date'], 'birth_date' => ['nullable', 'date'],

View File

@@ -33,6 +33,7 @@ class UpdateMemberRequest extends FormRequest
'email' => ['required', 'email', 'max:255'], 'email' => ['required', 'email', 'max:255'],
'national_id' => ['nullable', 'string', 'max:50'], 'national_id' => ['nullable', 'string', 'max:50'],
'phone' => ['nullable', 'string', 'max:50'], 'phone' => ['nullable', 'string', 'max:50'],
'line_id' => ['nullable', 'string', 'max:100'],
'phone_home' => ['nullable', 'string', 'max:50'], 'phone_home' => ['nullable', 'string', 'max:50'],
'phone_fax' => ['nullable', 'string', 'max:50'], 'phone_fax' => ['nullable', 'string', 'max:50'],
'birth_date' => ['nullable', 'date'], 'birth_date' => ['nullable', 'date'],

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PublicDocumentCollectionResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'slug' => $this->public_uuid,
'public_uuid' => $this->public_uuid,
'title' => $this->title,
'document_number' => $this->document_number,
'summary' => $this->description,
'description' => $this->description,
'status' => $this->status,
'status_label' => $this->getStatusLabel(),
'access_level' => $this->access_level,
'access_level_label' => $this->getAccessLevelLabel(),
'published_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
'expires_at' => $this->expires_at?->toDateString(),
'version_count' => $this->version_count,
'category' => $this->whenLoaded('category', function () {
return [
'id' => $this->category->id,
'name' => $this->category->name,
'slug' => $this->category->slug,
'icon' => $this->category->icon,
];
}),
'current_version' => $this->whenLoaded('currentVersion', function () {
if (! $this->currentVersion) {
return null;
}
return [
'id' => $this->currentVersion->id,
'version_number' => $this->currentVersion->version_number,
'version_notes' => $this->currentVersion->version_notes,
'is_current' => (bool) $this->currentVersion->is_current,
'original_filename' => $this->currentVersion->original_filename,
'mime_type' => $this->currentVersion->mime_type,
'file_extension' => $this->currentVersion->getFileExtension(),
'file_size' => $this->currentVersion->file_size,
'file_size_human' => $this->currentVersion->getFileSizeHuman(),
'file_hash' => $this->currentVersion->file_hash,
'uploaded_by' => $this->currentVersion->uploadedBy?->name,
'uploaded_at' => $this->currentVersion->uploaded_at?->toIso8601String(),
'download_url' => route('documents.public.download', $this->public_uuid),
];
}),
'links' => [
'api_url' => url('/api/v1/public-documents/'.$this->public_uuid),
'detail_url' => route('documents.public.show', $this->public_uuid),
'web_url' => route('documents.public.show', $this->public_uuid),
'download_url' => route('documents.public.download', $this->public_uuid),
],
'metadata' => [
'document_type' => $this->category?->name,
'expiration_status' => $this->getExpirationStatusLabel(),
'auto_archive_on_expiry' => (bool) $this->auto_archive_on_expiry,
'expiry_notice' => $this->expiry_notice,
],
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PublicDocumentResource extends JsonResource
{
public function toArray(Request $request): array
{
return array_merge(
(new PublicDocumentCollectionResource($this))->toArray($request),
[
'versions' => PublicDocumentVersionResource::collection($this->whenLoaded('versions')),
'audit' => [
'view_count' => $this->view_count,
'download_count' => $this->download_count,
'last_updated_by' => $this->lastUpdatedBy?->name,
'created_by' => $this->createdBy?->name,
],
]
);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PublicDocumentVersionResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'version_number' => $this->version_number,
'version_notes' => $this->version_notes,
'is_current' => (bool) $this->is_current,
'original_filename' => $this->original_filename,
'mime_type' => $this->mime_type,
'file_extension' => $this->getFileExtension(),
'file_size' => $this->file_size,
'file_size_human' => $this->getFileSizeHuman(),
'file_hash' => $this->file_hash,
'uploaded_by' => $this->uploadedBy?->name,
'uploaded_at' => $this->uploaded_at?->toIso8601String(),
'download_url' => route('documents.public.download-version', [
'uuid' => $this->document->public_uuid,
'version' => $this->id,
]),
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Jobs;
use App\Services\NextjsRepoSyncService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class PushNextjsPublicAssetsJob implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Collapse bursts of uploads into a single push.
*/
public int $uniqueFor = 60;
public function uniqueId(): string
{
return 'nextjs-public-assets-push';
}
public function handle(): void
{
NextjsRepoSyncService::pushPublicAssets();
}
}

View File

@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class Article extends Model class Article extends Model
@@ -47,6 +48,7 @@ class Article extends Model
'status', 'status',
'access_level', 'access_level',
'featured_image_path', 'featured_image_path',
'featured_image_storage_path',
'featured_image_alt', 'featured_image_alt',
'author_name', 'author_name',
'author_user_id', 'author_user_id',
@@ -447,7 +449,12 @@ class Article extends Model
return null; return null;
} }
// Return relative path for Next.js frontend (images served from public/) // Admin-uploaded images stored in Laravel storage
if (str_starts_with($this->featured_image_path, 'articles/')) {
return Storage::disk('public')->url($this->featured_image_path);
}
// Migrated images — relative path (served from Next.js public/)
return '/'.$this->featured_image_path; return '/'.$this->featured_image_path;
} }
} }

View File

@@ -42,6 +42,7 @@ class Member extends Model
'full_name', 'full_name',
'email', 'email',
'phone', 'phone',
'line_id',
'phone_home', 'phone_home',
'phone_fax', 'phone_fax',
'address_line_1', 'address_line_1',

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Observers;
use App\Models\Document;
use App\Services\SiteRevalidationService;
class DocumentObserver
{
private const RELEVANT_FIELDS = [
'title',
'document_number',
'description',
'document_category_id',
'access_level',
'status',
'archived_at',
'current_version_id',
'version_count',
'expires_at',
'auto_archive_on_expiry',
'expiry_notice',
'deleted_at',
];
public function created(Document $document): void
{
$this->revalidate($document);
}
public function updated(Document $document): void
{
if (! $document->wasChanged(self::RELEVANT_FIELDS)) {
return;
}
$this->revalidate($document);
}
public function deleted(Document $document): void
{
$this->revalidate($document);
}
public function restored(Document $document): void
{
$this->revalidate($document);
}
private function revalidate(Document $document): void
{
SiteRevalidationService::revalidateDocument($document->public_uuid);
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Providers; namespace App\Providers;
use App\Models\Document;
use App\Observers\DocumentObserver;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -19,6 +21,6 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
// Document::observe(DocumentObserver::class);
} }
} }

View File

@@ -0,0 +1,111 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
class NextjsRepoSyncService
{
/**
* Stage+commit+push assets under Next.js public/ that were copied by SiteAssetSyncService / ImportHugoContent.
*
* Best-effort and never throws: failure should not block the CMS workflow.
*/
public static function pushPublicAssets(): void
{
$repoPath = static::resolveRepoRoot();
if (! $repoPath) {
return;
}
$lock = Cache::lock('nextjs-repo-assets-push', 120);
if (! $lock->get()) {
return;
}
try {
if (! is_dir($repoPath.'/.git')) {
Log::warning("Next.js repo push skipped (no .git): {$repoPath}");
return;
}
$remote = (string) config('services.nextjs.repo_remote', 'origin');
$branch = (string) config('services.nextjs.repo_branch', 'main');
// Keep the repo up to date to reduce push failures.
static::run($repoPath, ['git', 'fetch', $remote]);
static::run($repoPath, ['git', 'checkout', $branch]);
static::run($repoPath, ['git', 'pull', '--rebase', $remote, $branch]);
// Only stage asset directories we manage.
static::run($repoPath, ['git', 'add', '-A', '--', 'public/uploads', 'public/images']);
$status = static::run($repoPath, ['git', 'status', '--porcelain']);
if (trim($status) === '') {
return;
}
$msg = (string) config('services.nextjs.repo_assets_commit_message', 'chore(assets): sync public assets');
$msg .= ' ('.now()->format('Y-m-d H:i:s').')';
static::run($repoPath, ['git', 'commit', '-m', $msg]);
static::run($repoPath, ['git', 'push', $remote, $branch]);
} catch (\Throwable $e) {
Log::warning('Next.js repo push failed: '.$e->getMessage());
} finally {
$lock->release();
}
}
public static function scheduleAssetsPush(): void
{
if (! config('services.nextjs.autopush_assets')) {
return;
}
// If queue is sync, this runs inline; otherwise requires a worker.
\App\Jobs\PushNextjsPublicAssetsJob::dispatch()->delay(now()->addSeconds(5));
}
private static function resolveRepoRoot(): ?string
{
$configured = config('services.nextjs.repo_path');
if (is_string($configured) && $configured !== '' && is_dir($configured)) {
return $configured;
}
$public = config('services.nextjs.public_path');
if (is_string($public) && $public !== '') {
$root = dirname($public);
if (is_dir($root)) {
return $root;
}
}
$guess = base_path('../usher-site');
if (is_dir($guess)) {
return $guess;
}
return null;
}
private static function run(string $cwd, array $cmd): string
{
$p = new Process($cmd, $cwd);
$p->setTimeout(60);
$p->run();
$out = (string) $p->getOutput();
$err = (string) $p->getErrorOutput();
if (! $p->isSuccessful()) {
throw new \RuntimeException('Command failed: '.implode(' ', $cmd)."\n".$err.$out);
}
return $out.$err;
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class SiteAssetSyncService
{
/**
* Copy a file from Laravel's public disk into usher-site/public so Next.js can serve it as a static asset.
*
* Returns the relative path under Next.js public/ (no leading slash), e.g. "uploads/articles/images/foo.jpg".
* If the Next.js public path is not configured / not present, returns null and callers should fall back
* to serving from Laravel storage.
*/
public static function syncPublicDiskFileToNext(string $publicDiskPath): ?string
{
$publicDiskPath = ltrim($publicDiskPath, '/');
$nextPublicRoot = static::resolveNextPublicRoot();
if (! $nextPublicRoot) {
return null;
}
$source = Storage::disk('public')->path($publicDiskPath);
if (! is_file($source)) {
Log::warning("Site asset sync skipped (source missing): {$publicDiskPath}");
return null;
}
$prefix = config('services.nextjs.public_upload_prefix', 'uploads');
$prefix = trim((string) $prefix, '/');
if ($prefix === '') {
$prefix = 'uploads';
}
$relativeDest = $prefix.'/'.$publicDiskPath;
$dest = rtrim($nextPublicRoot, '/').'/'.$relativeDest;
$destDir = dirname($dest);
if (! is_dir($destDir) && ! @mkdir($destDir, 0755, true) && ! is_dir($destDir)) {
Log::warning("Site asset sync failed (mkdir): {$destDir}");
return null;
}
if (! @copy($source, $dest)) {
Log::warning("Site asset sync failed (copy): {$publicDiskPath} -> {$dest}");
return null;
}
// Optionally commit+push the updated assets into the usher-site repo.
NextjsRepoSyncService::scheduleAssetsPush();
return $relativeDest;
}
/**
* Delete a previously synced Next.js public asset (best-effort).
*/
public static function deleteNextPublicFile(?string $nextPublicRelativePath): void
{
if (! $nextPublicRelativePath) {
return;
}
$nextPublicRoot = static::resolveNextPublicRoot();
if (! $nextPublicRoot) {
return;
}
$rel = ltrim($nextPublicRelativePath, '/');
$path = rtrim($nextPublicRoot, '/').'/'.$rel;
if (is_file($path)) {
@unlink($path);
}
// Stage deletion via scheduled push (best-effort).
NextjsRepoSyncService::scheduleAssetsPush();
}
private static function resolveNextPublicRoot(): ?string
{
$configured = config('services.nextjs.public_path');
if (is_string($configured) && $configured !== '' && is_dir($configured)) {
return $configured;
}
// Local-dev default: sibling repo next to this Laravel repo.
$guess = base_path('../usher-site/public');
if (is_dir($guess)) {
return $guess;
}
return null;
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class SiteRevalidationService
{
/**
* Revalidate article cache on the Next.js frontend.
*/
public static function revalidateArticle(?string $slug = null): void
{
static::revalidate('article', $slug);
}
/**
* Revalidate page cache on the Next.js frontend.
*/
public static function revalidatePage(?string $slug = null): void
{
static::revalidate('page', $slug);
}
/**
* Revalidate document cache on the Next.js frontend.
*/
public static function revalidateDocument(?string $slug = null): void
{
static::revalidate('document', $slug);
}
private static function revalidate(string $type, ?string $slug = null): void
{
if (app()->runningUnitTests()) {
return;
}
$url = config('services.nextjs.revalidate_url');
$token = config('services.nextjs.revalidate_token');
if (! $url || ! $token) {
return;
}
try {
$payload = ['type' => $type];
if ($slug) {
$payload['slug'] = $slug;
}
Http::timeout(5)
->withHeaders(['x-revalidate-token' => $token])
->post($url, $payload);
} catch (\Throwable $e) {
Log::warning("Site revalidation failed for {$type}/{$slug}: {$e->getMessage()}");
}
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class DownloadFile
{
/**
* Create a download response with UTF-8 filename support.
*/
public static function fromDisk(
string $disk,
string $path,
string $downloadName,
array $headers = []
): Response {
$safeName = static::asciiFallbackFilename($downloadName);
$response = Storage::disk($disk)->download($path, $safeName, $headers);
$response->headers->set('Content-Disposition', static::contentDisposition($downloadName));
return $response;
}
/**
* Build RFC 5987 compatible Content-Disposition value.
*/
public static function contentDisposition(
string $downloadName,
string $type = 'attachment'
): string {
$safeOriginalName = static::sanitizeHeaderFilename($downloadName);
$safeFallback = addcslashes(static::asciiFallbackFilename($safeOriginalName), "\"\\");
$encoded = rawurlencode($safeOriginalName);
return "{$type}; filename=\"{$safeFallback}\"; filename*=UTF-8''{$encoded}";
}
/**
* Generate ASCII fallback filename for old clients.
*/
public static function asciiFallbackFilename(string $filename): string
{
$filename = static::sanitizeHeaderFilename($filename);
$extension = pathinfo($filename, PATHINFO_EXTENSION);
$basename = pathinfo($filename, PATHINFO_FILENAME);
$asciiBase = Str::ascii($basename);
$asciiBase = preg_replace('/[^A-Za-z0-9._-]+/', '_', (string) $asciiBase);
$asciiBase = trim((string) $asciiBase, '._-');
if ($asciiBase === '') {
$asciiBase = 'download';
}
$asciiExtension = Str::ascii((string) $extension);
$asciiExtension = preg_replace('/[^A-Za-z0-9]+/', '', (string) $asciiExtension);
return $asciiExtension === ''
? $asciiBase
: "{$asciiBase}.{$asciiExtension}";
}
private static function sanitizeHeaderFilename(string $filename): string
{
$filename = str_replace(["\r", "\n"], '', trim($filename));
return $filename !== '' ? $filename : 'download';
}
}

View File

@@ -36,6 +36,13 @@ return [
'throw' => false, 'throw' => false,
], ],
// Private files remain under storage/app and are served via controller responses.
'private' => [
'driver' => 'local',
'root' => storage_path('app'),
'throw' => false,
],
'public' => [ 'public' => [
'driver' => 'local', 'driver' => 'local',
'root' => storage_path('app/public'), 'root' => storage_path('app/public'),

View File

@@ -31,4 +31,21 @@ return [
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
], ],
'nextjs' => [
'revalidate_url' => env('NEXTJS_REVALIDATE_URL'),
'revalidate_token' => env('NEXTJS_REVALIDATE_TOKEN'),
// Optional: sync assets into a local Next.js repo for static serving.
// Example: /Users/gbanyan/Project/usher-site/public
'public_path' => env('NEXTJS_PUBLIC_PATH'),
// Folder under public/ where Laravel uploads are copied (no leading/trailing slash).
'public_upload_prefix' => env('NEXTJS_PUBLIC_UPLOAD_PREFIX', 'uploads'),
// Optional: automatically git commit+push when public/ assets change.
// Requires the Next.js repo to be checked out and authenticated on this machine.
'autopush_assets' => env('NEXTJS_AUTOPUSH_ASSETS', false),
'repo_path' => env('NEXTJS_REPO_PATH'),
'repo_remote' => env('NEXTJS_REPO_REMOTE', 'origin'),
'repo_branch' => env('NEXTJS_REPO_BRANCH', 'main'),
'repo_assets_commit_message' => env('NEXTJS_REPO_ASSETS_COMMIT_MESSAGE', 'chore(assets): sync public assets'),
],
]; ];

View File

@@ -0,0 +1,28 @@
<?php
namespace Database\Factories;
use App\Models\ArticleCategory;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<ArticleCategory>
*/
class ArticleCategoryFactory extends Factory
{
protected $model = ArticleCategory::class;
public function definition(): array
{
$name = $this->faker->unique()->words(2, true);
return [
'name' => $name,
'slug' => Str::slug($name) ?: 'category-'.Str::random(8),
'description' => $this->faker->optional()->sentence(),
'sort_order' => $this->faker->numberBetween(0, 50),
];
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Database\Factories;
use App\Models\Article;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Article>
*/
class ArticleFactory extends Factory
{
protected $model = Article::class;
public function definition(): array
{
$title = $this->faker->sentence(6);
$slug = Str::slug($title) ?: 'article-'.Str::random(10);
return [
'title' => $title,
'slug' => $slug,
'summary' => $this->faker->optional()->paragraph(),
'content' => $this->faker->paragraphs(5, true),
'content_type' => Article::CONTENT_TYPE_BLOG,
'status' => Article::STATUS_PUBLISHED,
'access_level' => Article::ACCESS_LEVEL_PUBLIC,
'featured_image_path' => null,
'featured_image_alt' => null,
'author_name' => $this->faker->optional()->name(),
'author_user_id' => null,
'meta_description' => $this->faker->optional()->sentence(),
'meta_keywords' => null,
'is_pinned' => false,
'display_order' => 0,
'published_at' => now()->subDay(),
'expires_at' => null,
'archived_at' => null,
'view_count' => 0,
'created_by_user_id' => User::factory(),
'last_updated_by_user_id' => User::factory(),
];
}
public function draft(): static
{
return $this->state(fn () => [
'status' => Article::STATUS_DRAFT,
'published_at' => null,
]);
}
public function pinned(int $order = 0): static
{
return $this->state(fn () => [
'is_pinned' => true,
'display_order' => $order,
]);
}
public function expired(): static
{
return $this->state(fn () => [
'status' => Article::STATUS_PUBLISHED,
'expires_at' => now()->subDay(),
]);
}
public function scheduled(): static
{
return $this->state(fn () => [
'status' => Article::STATUS_PUBLISHED,
'published_at' => now()->addDay(),
]);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Database\Factories;
use App\Models\ArticleTag;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<ArticleTag>
*/
class ArticleTagFactory extends Factory
{
protected $model = ArticleTag::class;
public function definition(): array
{
$name = $this->faker->unique()->words(2, true);
return [
'name' => $name,
'slug' => Str::slug($name) ?: 'tag-'.Str::random(8),
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Database\Factories;
use App\Models\Page;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Page>
*/
class PageFactory extends Factory
{
protected $model = Page::class;
public function definition(): array
{
$title = $this->faker->sentence(4);
$slug = Str::slug($title) ?: 'page-'.Str::random(10);
return [
'title' => $title,
'slug' => $slug,
'content' => $this->faker->paragraphs(6, true),
'template' => null,
'custom_fields' => null,
'status' => Page::STATUS_PUBLISHED,
'meta_description' => $this->faker->optional()->sentence(),
'meta_keywords' => null,
'parent_id' => null,
'sort_order' => 0,
'published_at' => now()->subDay(),
'created_by_user_id' => User::factory(),
'last_updated_by_user_id' => User::factory(),
];
}
public function draft(): static
{
return $this->state(fn () => [
'status' => Page::STATUS_DRAFT,
'published_at' => null,
]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('articles', function (Blueprint $table) {
// Keep original Laravel storage path when we also sync into usher-site/public.
$table->string('featured_image_storage_path')->nullable()->after('featured_image_path');
});
}
public function down(): void
{
Schema::table('articles', function (Blueprint $table) {
$table->dropColumn('featured_image_storage_path');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('members', function (Blueprint $table) {
$table->string('line_id', 100)->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('members', function (Blueprint $table) {
$table->dropColumn('line_id');
});
}
};

View File

@@ -169,7 +169,7 @@
</p> </p>
</div> </div>
<div class="grid gap-6 sm:grid-cols-2"> <div class="grid gap-6 sm:grid-cols-3">
<div> <div>
<label for="phone" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label for="phone" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
行動電話 行動電話
@@ -186,6 +186,22 @@
@enderror @enderror
</div> </div>
<div>
<label for="line_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Line ID
</label>
<input
type="text"
name="line_id"
id="line_id"
value="{{ old('line_id') }}"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 sm:text-sm dark:bg-gray-900 dark:text-gray-300"
>
@error('line_id')
<p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</div>
<div> <div>
<label for="phone_home" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label for="phone_home" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
室內電話 室內電話

View File

@@ -167,7 +167,7 @@
</p> </p>
</div> </div>
<div class="grid gap-6 sm:grid-cols-2"> <div class="grid gap-6 sm:grid-cols-3">
<div> <div>
<label for="phone" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label for="phone" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
行動電話 行動電話
@@ -184,6 +184,22 @@
@enderror @enderror
</div> </div>
<div>
<label for="line_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Line ID
</label>
<input
type="text"
name="line_id"
id="line_id"
value="{{ old('line_id', $member->line_id) }}"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 sm:text-sm dark:bg-gray-900 dark:text-gray-300"
>
@error('line_id')
<p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</div>
<div> <div>
<label for="phone_home" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label for="phone_home" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
室內電話 室內電話

View File

@@ -17,6 +17,7 @@
<li><code class="text-gray-800 dark:text-gray-200">member_number</code> (optional)</li> <li><code class="text-gray-800 dark:text-gray-200">member_number</code> (optional)</li>
<li><code class="text-gray-800 dark:text-gray-200">email</code></li> <li><code class="text-gray-800 dark:text-gray-200">email</code></li>
<li><code class="text-gray-800 dark:text-gray-200">phone</code></li> <li><code class="text-gray-800 dark:text-gray-200">phone</code></li>
<li><code class="text-gray-800 dark:text-gray-200">line_id</code> (optional)</li>
<li><code class="text-gray-800 dark:text-gray-200">phone_home</code> (optional)</li> <li><code class="text-gray-800 dark:text-gray-200">phone_home</code> (optional)</li>
<li><code class="text-gray-800 dark:text-gray-200">phone_fax</code> (optional)</li> <li><code class="text-gray-800 dark:text-gray-200">phone_fax</code> (optional)</li>
<li><code class="text-gray-800 dark:text-gray-200">birth_date</code> (YYYY-MM-DD, optional)</li> <li><code class="text-gray-800 dark:text-gray-200">birth_date</code> (YYYY-MM-DD, optional)</li>

View File

@@ -12,7 +12,7 @@
<form method="GET" action="{{ route('admin.members.index') }}" class="mb-4 space-y-4" role="search" aria-label="搜尋和篩選會員"> <form method="GET" action="{{ route('admin.members.index') }}" class="mb-4 space-y-4" role="search" aria-label="搜尋和篩選會員">
<div> <div>
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
依姓名、電子郵件、電話或身分證號搜尋 依姓名、電子郵件、電話、Line ID 或身分證號搜尋
</label> </label>
<input <input
type="text" type="text"
@@ -23,7 +23,7 @@
placeholder="輸入搜尋關鍵字..." placeholder="輸入搜尋關鍵字..."
> >
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
在姓名、電子郵件、電話號碼和身分證號中搜尋 在姓名、電子郵件、電話號碼、Line ID 和身分證號中搜尋
</p> </p>
</div> </div>
@@ -297,4 +297,4 @@
}); });
}); });
</script> </script>
</x-app-layout> </x-app-layout>

View File

@@ -103,6 +103,15 @@
</dd> </dd>
</div> </div>
<div class="overflow-hidden rounded-lg bg-gray-50 dark:bg-gray-700 px-4 py-5 sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Line ID
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ $member->line_id ?? __('Not set') }}
</dd>
</div>
<div class="overflow-hidden rounded-lg bg-gray-50 dark:bg-gray-700 px-4 py-5 sm:p-6"> <div class="overflow-hidden rounded-lg bg-gray-50 dark:bg-gray-700 px-4 py-5 sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400"> <dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
室內電話/傳真 室內電話/傳真

View File

@@ -127,38 +127,46 @@
</div> </div>
<!-- Settings Dropdown --> <!-- Settings Dropdown -->
<div class="hidden sm:flex sm:items-center sm:ms-6"> @auth
<x-dropdown align="right" width="48"> <div class="hidden sm:flex sm:items-center sm:ms-6">
<x-slot name="trigger"> <x-dropdown align="right" width="48">
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150 dark:bg-slate-800 dark:text-slate-100 dark:hover:text-white"> <x-slot name="trigger">
<div>{{ Auth::user()->name }}</div> <button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150 dark:bg-slate-800 dark:text-slate-100 dark:hover:text-white">
<div>{{ Auth::user()->name }}</div>
<div class="ms-1"> <div class="ms-1">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> <svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg> </svg>
</div> </div>
</button> </button>
</x-slot> </x-slot>
<x-slot name="content"> <x-slot name="content">
<x-dropdown-link :href="route('profile.edit')"> <x-dropdown-link :href="route('profile.edit')">
個人檔案 個人檔案
</x-dropdown-link>
<!-- Authentication -->
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-dropdown-link :href="route('logout')"
onclick="event.preventDefault();
this.closest('form').submit();">
登出
</x-dropdown-link> </x-dropdown-link>
</form>
</x-slot> <!-- Authentication -->
</x-dropdown> <form method="POST" action="{{ route('logout') }}">
</div> @csrf
<x-dropdown-link :href="route('logout')"
onclick="event.preventDefault();
this.closest('form').submit();">
登出
</x-dropdown-link>
</form>
</x-slot>
</x-dropdown>
</div>
@else
<div class="hidden sm:flex sm:items-center sm:ms-6">
<x-nav-link :href="route('login')" :active="request()->routeIs('login')">
登入
</x-nav-link>
</div>
@endauth
<!-- Hamburger --> <!-- Hamburger -->
<div class="-me-2 flex items-center sm:hidden"> <div class="-me-2 flex items-center sm:hidden">
@@ -279,28 +287,38 @@
</div> </div>
<!-- Responsive Settings Options --> <!-- Responsive Settings Options -->
<div class="pt-4 pb-1 border-t border-gray-200 dark:border-gray-700"> @auth
<div class="px-4"> <div class="pt-4 pb-1 border-t border-gray-200 dark:border-gray-700">
<div class="font-medium text-base text-gray-800 dark:text-slate-200">{{ Auth::user()->name }}</div> <div class="px-4">
<div class="font-medium text-sm text-gray-500 dark:text-slate-400">{{ Auth::user()->email }}</div> <div class="font-medium text-base text-gray-800 dark:text-slate-200">{{ Auth::user()->name }}</div>
</div> <div class="font-medium text-sm text-gray-500 dark:text-slate-400">{{ Auth::user()->email }}</div>
</div>
<div class="mt-3 space-y-1"> <div class="mt-3 space-y-1">
<x-responsive-nav-link :href="route('profile.edit')"> <x-responsive-nav-link :href="route('profile.edit')">
個人檔案 個人檔案
</x-responsive-nav-link>
<!-- Authentication -->
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-responsive-nav-link :href="route('logout')"
onclick="event.preventDefault();
this.closest('form').submit();">
登出
</x-responsive-nav-link> </x-responsive-nav-link>
</form>
<!-- Authentication -->
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-responsive-nav-link :href="route('logout')"
onclick="event.preventDefault();
this.closest('form').submit();">
登出
</x-responsive-nav-link>
</form>
</div>
</div> </div>
</div> @else
<div class="pt-4 pb-1 border-t border-gray-200 dark:border-gray-700">
<div class="mt-3 space-y-1">
<x-responsive-nav-link :href="route('login')" :active="request()->routeIs('login')">
登入
</x-responsive-nav-link>
</div>
</div>
@endauth
</div> </div>
</nav> </nav>

View File

@@ -34,6 +34,13 @@
<x-input-error :messages="$errors->get('phone')" class="mt-2" /> <x-input-error :messages="$errors->get('phone')" class="mt-2" />
</div> </div>
<!-- Line ID -->
<div class="mt-4">
<x-input-label for="line_id" :value="__('Line ID')" />
<x-text-input id="line_id" class="block mt-1 w-full" type="text" name="line_id" :value="old('line_id')" maxlength="100" />
<x-input-error :messages="$errors->get('line_id')" class="mt-2" />
</div>
<!-- National ID (Optional) --> <!-- National ID (Optional) -->
<div class="mt-4"> <div class="mt-4">
<x-input-label for="national_id" :value="__('National ID (Optional)')" /> <x-input-label for="national_id" :value="__('National ID (Optional)')" />

View File

@@ -92,6 +92,15 @@
</dd> </dd>
</div> </div>
<div class="overflow-hidden rounded-lg bg-gray-50 dark:bg-gray-700 px-4 py-5 sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
{{ __('Line ID') }}
</dt>
<dd class="mt-1 text-xl font-semibold text-gray-900 dark:text-gray-100">
{{ $member->line_id ?: __('Not set') }}
</dd>
</div>
<div class="overflow-hidden rounded-lg bg-gray-50 dark:bg-gray-700 px-4 py-5 sm:p-6"> <div class="overflow-hidden rounded-lg bg-gray-50 dark:bg-gray-700 px-4 py-5 sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400"> <dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
{{ __('Membership Type') }} {{ __('Membership Type') }}

View File

@@ -50,6 +50,13 @@
<x-input-error :messages="$errors->get('phone')" class="mt-2" /> <x-input-error :messages="$errors->get('phone')" class="mt-2" />
</div> </div>
<!-- Line ID -->
<div class="mt-4">
<x-input-label for="line_id" :value="__('Line ID')" />
<x-text-input id="line_id" class="block mt-1 w-full" type="text" name="line_id" :value="old('line_id')" maxlength="100" />
<x-input-error :messages="$errors->get('line_id')" class="mt-2" />
</div>
<!-- National ID (Optional) --> <!-- National ID (Optional) -->
<div class="mt-4"> <div class="mt-4">
<x-input-label for="national_id" :value="__('National ID (Optional)')" /> <x-input-label for="national_id" :value="__('National ID (Optional)')" />

View File

@@ -3,6 +3,7 @@
use App\Http\Controllers\Api\ArticleController; use App\Http\Controllers\Api\ArticleController;
use App\Http\Controllers\Api\HomepageController; use App\Http\Controllers\Api\HomepageController;
use App\Http\Controllers\Api\PageController; use App\Http\Controllers\Api\PageController;
use App\Http\Controllers\Api\PublicDocumentController;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -44,4 +45,9 @@ Route::prefix('v1')->group(function () {
// Homepage // Homepage
Route::get('/homepage', [HomepageController::class, 'index']); Route::get('/homepage', [HomepageController::class, 'index']);
// Public documents (from member document library)
Route::get('/public-documents', [PublicDocumentController::class, 'index']);
Route::get('/public-documents/{uuid}', [PublicDocumentController::class, 'show'])
->whereUuid('uuid');
}); });

View File

@@ -0,0 +1,55 @@
<?php
namespace Tests\Feature\Cms;
use App\Models\User;
use Database\Seeders\FinancialWorkflowPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class AdminCmsAccessTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->artisan('db:seed', ['--class' => FinancialWorkflowPermissionsSeeder::class]);
}
public function test_user_without_permissions_cannot_access_admin_articles(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->get('/admin/articles')
->assertForbidden();
}
public function test_secretary_general_can_access_admin_articles_and_create_draft(): void
{
$role = Role::where('name', 'secretary_general')->firstOrFail();
$user = User::factory()->create();
$user->assignRole($role);
$this->actingAs($user)
->get('/admin/articles')
->assertOk();
$res = $this->actingAs($user)->post('/admin/articles', [
'title' => 'Test Article',
'content' => 'Hello world',
'content_type' => 'blog',
'access_level' => 'public',
'save_action' => 'draft',
]);
$res->assertRedirect();
$this->assertDatabaseHas('articles', [
'title' => 'Test Article',
'status' => 'draft',
]);
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace Tests\Feature\Cms;
use App\Models\Article;
use App\Models\ArticleCategory;
use App\Models\ArticleTag;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ArticleApiTest extends TestCase
{
use RefreshDatabase;
public function test_articles_index_only_returns_active_and_public_for_guests(): void
{
Article::factory()->create(['title' => 'Visible']);
Article::factory()->draft()->create(['title' => 'Draft']);
Article::factory()->expired()->create(['title' => 'Expired']);
Article::factory()->scheduled()->create(['title' => 'Scheduled']);
$res = $this->getJson('/api/v1/articles?per_page=50');
$res->assertOk();
$res->assertJsonCount(1, 'data');
$res->assertJsonFragment(['title' => 'Visible']);
$res->assertJsonMissing(['title' => 'Draft']);
$res->assertJsonMissing(['title' => 'Expired']);
$res->assertJsonMissing(['title' => 'Scheduled']);
}
public function test_articles_index_filters_by_type_category_tag_and_search(): void
{
$cat = ArticleCategory::factory()->create(['slug' => 'news']);
$tag = ArticleTag::factory()->create(['slug' => 'health']);
$a1 = Article::factory()->create([
'content_type' => Article::CONTENT_TYPE_NOTICE,
'title' => 'Needle Drop',
'content' => 'alpha beta',
]);
$a1->categories()->attach($cat);
$a1->tags()->attach($tag);
Article::factory()->create([
'content_type' => Article::CONTENT_TYPE_BLOG,
'title' => 'Other',
'content' => 'gamma delta',
]);
$this->getJson('/api/v1/articles?type=notice&per_page=50')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonFragment(['slug' => $a1->slug]);
$this->getJson('/api/v1/articles?category=news&per_page=50')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonFragment(['slug' => $a1->slug]);
$this->getJson('/api/v1/articles?tag=health&per_page=50')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonFragment(['slug' => $a1->slug]);
$this->getJson('/api/v1/articles?search=needle&per_page=50')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonFragment(['slug' => $a1->slug]);
}
public function test_articles_show_returns_wrapped_data_and_increments_view_count(): void
{
$article = Article::factory()->create([
'view_count' => 0,
'title' => 'My Article',
]);
$this->getJson('/api/v1/articles/'.$article->slug)
->assertOk()
->assertJsonPath('data.slug', $article->slug)
->assertJsonStructure([
'data' => ['id', 'title', 'slug', 'content'],
'related' => [
'*' => ['id', 'title', 'slug', 'content_type'],
],
]);
$this->assertSame(1, $article->refresh()->view_count);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Tests\Feature\Cms;
use App\Models\Article;
use App\Models\Page;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class HomepageApiTest extends TestCase
{
use RefreshDatabase;
public function test_homepage_endpoint_returns_expected_sections(): void
{
Article::factory()->pinned(0)->create(['content_type' => Article::CONTENT_TYPE_BLOG]);
Article::factory()->pinned(1)->create(['content_type' => Article::CONTENT_TYPE_NOTICE]);
Article::factory()->create(['content_type' => Article::CONTENT_TYPE_BLOG]);
Article::factory()->create(['content_type' => Article::CONTENT_TYPE_NOTICE]);
Article::factory()->create(['content_type' => Article::CONTENT_TYPE_DOCUMENT]);
Article::factory()->create(['content_type' => Article::CONTENT_TYPE_RELATED_NEWS]);
$about = Page::factory()->create(['slug' => 'about']);
$this->getJson('/api/v1/homepage')
->assertOk()
->assertJsonStructure([
'featured' => [
'*' => ['id', 'title', 'slug', 'content_type'],
],
'latest_blog' => [
'*' => ['id', 'title', 'slug', 'content_type'],
],
'latest_notice' => [
'*' => ['id', 'title', 'slug', 'content_type'],
],
'latest_document' => [
'*' => ['id', 'title', 'slug', 'content_type'],
],
'latest_related_news' => [
'*' => ['id', 'title', 'slug', 'content_type'],
],
'about' => ['id', 'slug', 'content'],
'categories' => [
'*' => ['id', 'name', 'slug'],
],
])
->assertJsonPath('about.slug', $about->slug);
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace Tests\Feature\Cms;
use App\Models\Article;
use App\Models\ArticleAttachment;
use App\Models\Document;
use App\Models\DocumentCategory;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class ImportArticleDocumentsCommandTest extends TestCase
{
use RefreshDatabase;
public function test_it_imports_article_documents_and_is_idempotent(): void
{
Storage::fake('public');
Storage::fake('private');
$user = User::factory()->create();
DocumentCategory::factory()->create([
'slug' => 'organization-public-disclosure',
'name' => '組織公開資訊',
'default_access_level' => 'public',
]);
$articleWithAttachment = Article::factory()->create([
'title' => '舊版章程 PDF',
'slug' => 'legacy-charter',
'content_type' => Article::CONTENT_TYPE_DOCUMENT,
'status' => Article::STATUS_PUBLISHED,
'access_level' => Article::ACCESS_LEVEL_PUBLIC,
'summary' => '舊版章程摘要',
'content' => '舊版章程內容',
'created_by_user_id' => $user->id,
'last_updated_by_user_id' => $user->id,
]);
Storage::disk('public')->put('articles/attachments/legacy-charter.pdf', '%PDF-1.4 test');
ArticleAttachment::create([
'article_id' => $articleWithAttachment->id,
'file_path' => 'articles/attachments/legacy-charter.pdf',
'original_filename' => 'legacy-charter.pdf',
'mime_type' => 'application/pdf',
'file_size' => 13,
'description' => '舊版附件',
]);
$articleWithoutAttachment = Article::factory()->create([
'title' => '純文字福利資源',
'slug' => 'legacy-welfare-links',
'content_type' => Article::CONTENT_TYPE_DOCUMENT,
'status' => Article::STATUS_PUBLISHED,
'access_level' => Article::ACCESS_LEVEL_PUBLIC,
'summary' => '福利連結摘要',
'content' => "第一行\n第二行",
'created_by_user_id' => $user->id,
'last_updated_by_user_id' => $user->id,
]);
$this->artisan('articles:import-documents', [
'--fallback-user-id' => $user->id,
])->assertExitCode(0);
$this->assertDatabaseCount('documents', 2);
$importedFromAttachment = Document::where('title', $articleWithAttachment->title)->firstOrFail();
$this->assertSame('public', $importedFromAttachment->access_level);
$this->assertSame('active', $importedFromAttachment->status);
$this->assertSame(1, $importedFromAttachment->version_count);
$this->assertNotNull($importedFromAttachment->current_version_id);
$this->assertTrue($importedFromAttachment->currentVersion()->exists());
$this->assertSame('legacy-charter.pdf', $importedFromAttachment->currentVersion->original_filename);
$this->assertTrue(Storage::disk('private')->exists($importedFromAttachment->currentVersion->file_path));
$importedFromMarkdown = Document::where('title', $articleWithoutAttachment->title)->firstOrFail();
$this->assertSame(1, $importedFromMarkdown->version_count);
$this->assertNotNull($importedFromMarkdown->current_version_id);
$this->assertSame('text/markdown', $importedFromMarkdown->currentVersion->mime_type);
$this->assertSame('legacy-welfare-links.md', $importedFromMarkdown->currentVersion->original_filename);
$this->assertTrue(Storage::disk('private')->exists($importedFromMarkdown->currentVersion->file_path));
$markdown = Storage::disk('private')->get($importedFromMarkdown->currentVersion->file_path);
$this->assertStringContainsString('# 純文字福利資源', $markdown);
$this->assertStringContainsString('Source article slug: `legacy-welfare-links`', $markdown);
// Idempotency check
$this->artisan('articles:import-documents', [
'--fallback-user-id' => $user->id,
])->assertExitCode(0);
$this->assertDatabaseCount('documents', 2);
}
public function test_it_can_archive_source_articles_after_import(): void
{
Storage::fake('public');
Storage::fake('private');
$user = User::factory()->create();
DocumentCategory::factory()->create([
'slug' => 'organization-public-disclosure',
'name' => '組織公開資訊',
'default_access_level' => 'public',
]);
$article = Article::factory()->create([
'title' => '待封存來源文章',
'slug' => 'legacy-archive-me',
'content_type' => Article::CONTENT_TYPE_DOCUMENT,
'status' => Article::STATUS_PUBLISHED,
'access_level' => Article::ACCESS_LEVEL_PUBLIC,
'created_by_user_id' => $user->id,
'last_updated_by_user_id' => $user->id,
]);
$this->artisan('articles:import-documents', [
'--fallback-user-id' => $user->id,
'--mark-archived' => true,
])->assertExitCode(0);
$article->refresh();
$this->assertSame(Article::STATUS_ARCHIVED, $article->status);
$this->assertNotNull($article->archived_at);
$this->assertDatabaseCount('documents', 1);
}
public function test_it_can_archive_already_imported_source_articles(): void
{
Storage::fake('public');
Storage::fake('private');
$user = User::factory()->create();
DocumentCategory::factory()->create([
'slug' => 'organization-public-disclosure',
'name' => '組織公開資訊',
'default_access_level' => 'public',
]);
$article = Article::factory()->create([
'title' => '先匯入後封存',
'slug' => 'legacy-import-then-archive',
'content_type' => Article::CONTENT_TYPE_DOCUMENT,
'status' => Article::STATUS_PUBLISHED,
'access_level' => Article::ACCESS_LEVEL_PUBLIC,
'created_by_user_id' => $user->id,
'last_updated_by_user_id' => $user->id,
]);
$this->artisan('articles:import-documents', [
'--fallback-user-id' => $user->id,
])->assertExitCode(0);
$this->assertDatabaseCount('documents', 1);
$article->refresh();
$this->assertSame(Article::STATUS_PUBLISHED, $article->status);
$this->artisan('articles:import-documents', [
'--fallback-user-id' => $user->id,
'--mark-archived' => true,
])->assertExitCode(0);
$article->refresh();
$this->assertSame(Article::STATUS_ARCHIVED, $article->status);
$this->assertNotNull($article->archived_at);
$this->assertDatabaseCount('documents', 1);
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace Tests\Feature\Cms;
use App\Models\Document;
use App\Models\DocumentCategory;
use App\Models\DocumentVersion;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class PublicDocumentApiTest extends TestCase
{
use RefreshDatabase;
public function test_public_documents_index_only_returns_active_public_documents(): void
{
$user = User::factory()->create();
$category = DocumentCategory::factory()->create(['slug' => 'legal-docs', 'name' => '法規文件']);
$visible = $this->createDocumentWithVersion($user, $category, [
'title' => '可見文件',
'access_level' => 'public',
'status' => 'active',
]);
$this->createDocumentWithVersion($user, $category, [
'title' => '非公開文件',
'access_level' => 'members',
'status' => 'active',
]);
$this->createDocumentWithVersion($user, $category, [
'title' => '封存文件',
'access_level' => 'public',
'status' => 'archived',
]);
$this->getJson('/api/v1/public-documents?per_page=50')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.slug', $visible->public_uuid)
->assertJsonPath('data.0.current_version.version_number', '1.0')
->assertJsonPath('data.0.category.slug', 'legal-docs')
->assertJsonMissing(['title' => '非公開文件'])
->assertJsonMissing(['title' => '封存文件']);
}
public function test_public_documents_show_returns_versions_and_related_documents(): void
{
$user = User::factory()->create();
$category = DocumentCategory::factory()->create(['slug' => 'governance', 'name' => '治理文件']);
$document = $this->createDocumentWithVersion($user, $category, [
'title' => '主文件',
'description' => '主文件描述',
'access_level' => 'public',
'status' => 'active',
]);
$secondVersion = DocumentVersion::create([
'document_id' => $document->id,
'version_number' => '1.1',
'version_notes' => '更新內容',
'is_current' => true,
'file_path' => 'documents/test-main-v1_1.pdf',
'original_filename' => 'test-main-v1_1.pdf',
'mime_type' => 'application/pdf',
'file_size' => 4096,
'file_hash' => Str::random(64),
'uploaded_by_user_id' => $user->id,
'uploaded_at' => now(),
]);
$document->versions()->where('id', '!=', $secondVersion->id)->update(['is_current' => false]);
$document->update([
'current_version_id' => $secondVersion->id,
'version_count' => 2,
'last_updated_by_user_id' => $user->id,
]);
$related = $this->createDocumentWithVersion($user, $category, [
'title' => '相關文件',
'access_level' => 'public',
'status' => 'active',
]);
$this->getJson('/api/v1/public-documents/'.$document->public_uuid)
->assertOk()
->assertJsonPath('data.slug', $document->public_uuid)
->assertJsonPath('data.title', '主文件')
->assertJsonPath('data.version_count', 2)
->assertJsonPath('data.current_version.version_number', '1.1')
->assertJsonPath('data.versions.0.version_number', '1.1')
->assertJsonPath('related.0.slug', $related->public_uuid);
}
private function createDocumentWithVersion(User $user, DocumentCategory $category, array $overrides = []): Document
{
$document = Document::factory()->create(array_merge([
'document_category_id' => $category->id,
'created_by_user_id' => $user->id,
'last_updated_by_user_id' => $user->id,
'access_level' => 'public',
'status' => 'active',
'version_count' => 0,
], $overrides));
$version = DocumentVersion::create([
'document_id' => $document->id,
'version_number' => '1.0',
'version_notes' => '初始版本',
'is_current' => true,
'file_path' => 'documents/test-'.$document->id.'.pdf',
'original_filename' => 'test-'.$document->id.'.pdf',
'mime_type' => 'application/pdf',
'file_size' => 2048,
'file_hash' => Str::random(64),
'uploaded_by_user_id' => $user->id,
'uploaded_at' => now()->subHour(),
]);
$document->update([
'current_version_id' => $version->id,
'version_count' => 1,
]);
return $document->fresh();
}
}

View File

@@ -239,4 +239,44 @@ class DocumentTest extends TestCase
$document->refresh(); $document->refresh();
$this->assertEquals(2, $document->version_count); $this->assertEquals(2, $document->version_count);
} }
/**
* Test public download returns UTF-8 compatible content disposition
*/
public function test_public_document_download_uses_utf8_content_disposition(): void
{
$category = DocumentCategory::factory()->create();
$document = Document::factory()->create([
'created_by_user_id' => $this->admin->id,
'document_category_id' => $category->id,
'access_level' => 'public',
'status' => 'active',
'version_count' => 0,
]);
$filePath = 'documents/charter-v2.pdf';
Storage::disk('private')->put($filePath, 'pdf-content');
Storage::put($filePath, 'pdf-content');
$document->addVersion(
filePath: $filePath,
originalFilename: '台灣尤塞氏症暨視聽弱協會章程V2.pdf',
mimeType: 'application/pdf',
fileSize: Storage::disk('private')->size($filePath),
uploadedBy: $this->admin,
versionNotes: '初始版本'
);
$response = $this->get(route('documents.public.download', [
'uuid' => $document->public_uuid,
]));
$response->assertOk();
$disposition = (string) $response->headers->get('Content-Disposition');
$this->assertStringContainsString(
"filename*=UTF-8''".rawurlencode('台灣尤塞氏症暨視聽弱協會章程V2.pdf'),
$disposition
);
}
} }

View File

@@ -41,6 +41,7 @@ class MemberRegistrationTest extends TestCase
'password' => 'Password123!', 'password' => 'Password123!',
'password_confirmation' => 'Password123!', 'password_confirmation' => 'Password123!',
'phone' => '0912345678', 'phone' => '0912345678',
'line_id' => 'john_doe_line',
'address_line_1' => '123 Test St', 'address_line_1' => '123 Test St',
'city' => 'Taipei', 'city' => 'Taipei',
'postal_code' => '100', 'postal_code' => '100',
@@ -62,6 +63,7 @@ class MemberRegistrationTest extends TestCase
'full_name' => 'John Doe', 'full_name' => 'John Doe',
'email' => 'john@example.com', 'email' => 'john@example.com',
'phone' => '0912345678', 'phone' => '0912345678',
'line_id' => 'john_doe_line',
'membership_status' => Member::STATUS_PENDING, 'membership_status' => Member::STATUS_PENDING,
]); ]);
} }
@@ -256,6 +258,7 @@ class MemberRegistrationTest extends TestCase
$member = Member::where('email', 'john@example.com')->first(); $member = Member::where('email', 'john@example.com')->first();
$this->assertNull($member->phone); $this->assertNull($member->phone);
$this->assertNull($member->line_id);
$this->assertNull($member->address_line_1); $this->assertNull($member->address_line_1);
} }
} }

View File

@@ -132,6 +132,7 @@ trait CreatesMemberData
'password' => 'Password123!', 'password' => 'Password123!',
'password_confirmation' => 'Password123!', 'password_confirmation' => 'Password123!',
'phone' => '0912345678', 'phone' => '0912345678',
'line_id' => 'line.member.test',
'national_id' => 'A123456789', 'national_id' => 'A123456789',
'address_line_1' => '123 Test Street', 'address_line_1' => '123 Test Street',
'address_line_2' => '', 'address_line_2' => '',

View File

@@ -0,0 +1,28 @@
<?php
namespace Tests\Unit;
use App\Support\DownloadFile;
use Tests\TestCase;
class DownloadFileTest extends TestCase
{
public function test_ascii_fallback_filename_keeps_extension(): void
{
$fallback = DownloadFile::asciiFallbackFilename('台灣尤塞氏症暨視聽弱協會章程V2.pdf');
$this->assertSame('V2.pdf', $fallback);
}
public function test_content_disposition_contains_utf8_filename_star(): void
{
$filename = '台灣尤塞氏症暨視聽弱協會章程V2.pdf';
$header = DownloadFile::contentDisposition($filename);
$this->assertStringContainsString('attachment; filename="V2.pdf"', $header);
$this->assertStringContainsString(
"filename*=UTF-8''".rawurlencode($filename),
$header
);
}
}