diff --git a/app/Http/Controllers/Api/PublicDocumentController.php b/app/Http/Controllers/Api/PublicDocumentController.php new file mode 100644 index 0000000..b44f8ed --- /dev/null +++ b/app/Http/Controllers/Api/PublicDocumentController.php @@ -0,0 +1,89 @@ +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), + ]); + } +} diff --git a/app/Http/Resources/PublicDocumentCollectionResource.php b/app/Http/Resources/PublicDocumentCollectionResource.php new file mode 100644 index 0000000..fc71dc1 --- /dev/null +++ b/app/Http/Resources/PublicDocumentCollectionResource.php @@ -0,0 +1,71 @@ + $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, + ], + ]; + } +} diff --git a/app/Http/Resources/PublicDocumentResource.php b/app/Http/Resources/PublicDocumentResource.php new file mode 100644 index 0000000..9e6a0b4 --- /dev/null +++ b/app/Http/Resources/PublicDocumentResource.php @@ -0,0 +1,25 @@ +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, + ], + ] + ); + } +} diff --git a/app/Http/Resources/PublicDocumentVersionResource.php b/app/Http/Resources/PublicDocumentVersionResource.php new file mode 100644 index 0000000..c4cd104 --- /dev/null +++ b/app/Http/Resources/PublicDocumentVersionResource.php @@ -0,0 +1,31 @@ + $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, + ]), + ]; + } +} diff --git a/app/Observers/DocumentObserver.php b/app/Observers/DocumentObserver.php new file mode 100644 index 0000000..6d41956 --- /dev/null +++ b/app/Observers/DocumentObserver.php @@ -0,0 +1,54 @@ +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); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..99934fd 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Models\Document; +use App\Observers\DocumentObserver; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -19,6 +21,6 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + Document::observe(DocumentObserver::class); } } diff --git a/app/Services/SiteRevalidationService.php b/app/Services/SiteRevalidationService.php index b9d6cef..80a8f14 100644 --- a/app/Services/SiteRevalidationService.php +++ b/app/Services/SiteRevalidationService.php @@ -23,8 +23,20 @@ class SiteRevalidationService 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'); diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index d23488a..a214f53 100644 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -127,38 +127,46 @@ - + + +
+ @csrf + + + 登出 + +
+ + + + @else + + @endauth
@@ -279,28 +287,38 @@
-
-
-
{{ Auth::user()->name }}
-
{{ Auth::user()->email }}
-
+ @auth +
+
+
{{ Auth::user()->name }}
+
{{ Auth::user()->email }}
+
-
- - 個人檔案 - - - -
- @csrf - - - 登出 +
+ + 個人檔案 - + + +
+ @csrf + + + 登出 + +
+
-
+ @else +
+
+ + 登入 + +
+
+ @endauth
diff --git a/routes/api.php b/routes/api.php index 4ef3270..90831ff 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Api\ArticleController; use App\Http\Controllers\Api\HomepageController; use App\Http\Controllers\Api\PageController; +use App\Http\Controllers\Api\PublicDocumentController; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; @@ -44,4 +45,9 @@ Route::prefix('v1')->group(function () { // Homepage 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'); }); diff --git a/tests/Feature/Cms/PublicDocumentApiTest.php b/tests/Feature/Cms/PublicDocumentApiTest.php new file mode 100644 index 0000000..e0e6c8f --- /dev/null +++ b/tests/Feature/Cms/PublicDocumentApiTest.php @@ -0,0 +1,131 @@ +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(); + } +}