Compare commits
8 Commits
860dbfb54e
...
7095be82d5
| Author | SHA1 | Date | |
|---|---|---|---|
| 7095be82d5 | |||
| ec2b1d21f4 | |||
| 4e7ef92d0b | |||
| b6e18a83ec | |||
| c4969cd4d2 | |||
| 097e332608 | |||
| 2a98d22740 | |||
| f0dbea1af5 |
16
.env.example
16
.env.example
@@ -57,3 +57,19 @@ VITE_PUSHER_HOST="${PUSHER_HOST}"
|
||||
VITE_PUSHER_PORT="${PUSHER_PORT}"
|
||||
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
|
||||
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
2
.gitignore
vendored
@@ -16,6 +16,8 @@ Homestead.yaml
|
||||
auth.json
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
/.fleet
|
||||
/.idea
|
||||
/.vscode
|
||||
|
||||
404
app/Console/Commands/ImportArticleDocuments.php
Normal file
404
app/Console/Commands/ImportArticleDocuments.php
Normal 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, '-');
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ class ImportHugoContent extends Command
|
||||
|
||||
private int $tagsCreated = 0;
|
||||
|
||||
private int $imagescopied = 0;
|
||||
private int $imagesCopied = 0;
|
||||
|
||||
private int $skipped = 0;
|
||||
|
||||
@@ -140,7 +140,7 @@ class ImportHugoContent extends Command
|
||||
['Pages created', $this->pagesCreated],
|
||||
['Categories created', $this->categoriesCreated],
|
||||
['Tags created', $this->tagsCreated],
|
||||
['Images copied', $this->imagescopied],
|
||||
['Images copied', $this->imagesCopied],
|
||||
['Skipped (_index.md etc.)', $this->skipped],
|
||||
]
|
||||
);
|
||||
@@ -360,7 +360,15 @@ class ImportHugoContent extends Command
|
||||
{
|
||||
$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)) {
|
||||
File::makeDirectory($destPath, 0755, true);
|
||||
@@ -382,7 +390,12 @@ class ImportHugoContent extends Command
|
||||
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.
|
||||
* 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
|
||||
{
|
||||
@@ -519,9 +532,13 @@ class ImportHugoContent extends Command
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove leading "images/" prefix
|
||||
$relativePath = preg_replace('#^images/#', '', $hugoPath);
|
||||
$path = ltrim($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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ class ImportMembers extends Command
|
||||
$memberNumber = isset($indexes['member_number']) ? trim($row[$indexes['member_number']] ?? '') : '';
|
||||
$nationalId = isset($indexes['national_id']) ? trim($row[$indexes['national_id']] ?? '') : '';
|
||||
$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']] ?? '') : '';
|
||||
$phoneFax = isset($indexes['phone_fax']) ? trim($row[$indexes['phone_fax']] ?? '') : '';
|
||||
$birthDate = isset($indexes['birth_date']) ? trim($row[$indexes['birth_date']] ?? '') : '';
|
||||
@@ -125,6 +126,7 @@ class ImportMembers extends Command
|
||||
'email' => $email,
|
||||
'national_id' => $nationalId !== '' ? $nationalId : null,
|
||||
'phone' => $phone !== '' ? $phone : null,
|
||||
'line_id' => $lineId !== '' ? $lineId : null,
|
||||
'phone_home' => $phoneHome !== '' ? $phoneHome : null,
|
||||
'phone_fax' => $phoneFax !== '' ? $phoneFax : null,
|
||||
'birth_date' => $birthDate !== '' ? $birthDate : null,
|
||||
|
||||
22
app/Console/Commands/NextjsPushAssets.php
Normal file
22
app/Console/Commands/NextjsPushAssets.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
174
app/Console/Commands/ReclassifyDocumentContent.php
Normal file
174
app/Console/Commands/ReclassifyDocumentContent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ use App\Models\ArticleAttachment;
|
||||
use App\Models\ArticleCategory;
|
||||
use App\Models\ArticleTag;
|
||||
use App\Models\AuditLog;
|
||||
use App\Services\SiteAssetSyncService;
|
||||
use App\Services\SiteRevalidationService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -117,7 +119,15 @@ class ArticleController extends Controller
|
||||
];
|
||||
|
||||
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);
|
||||
@@ -148,6 +158,10 @@ class ArticleController extends Controller
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
if ($validated['save_action'] === 'publish') {
|
||||
SiteRevalidationService::revalidateArticle($article->slug);
|
||||
}
|
||||
|
||||
$message = $validated['save_action'] === 'publish' ? '文章已成功發布' : '文章已儲存為草稿';
|
||||
|
||||
return redirect()
|
||||
@@ -227,13 +241,35 @@ class ArticleController extends Controller
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
$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) {
|
||||
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_storage_path'] = null;
|
||||
}
|
||||
|
||||
$article->update($updateData);
|
||||
@@ -264,6 +300,10 @@ class ArticleController extends Controller
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
if ($article->isPublished()) {
|
||||
SiteRevalidationService::revalidateArticle($article->slug);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.articles.show', $article)
|
||||
->with('status', '文章已成功更新');
|
||||
@@ -276,6 +316,8 @@ class ArticleController extends Controller
|
||||
}
|
||||
|
||||
$title = $article->title;
|
||||
$slug = $article->slug;
|
||||
$wasPublished = $article->isPublished();
|
||||
$article->delete();
|
||||
|
||||
AuditLog::create([
|
||||
@@ -285,6 +327,10 @@ class ArticleController extends Controller
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
if ($wasPublished) {
|
||||
SiteRevalidationService::revalidateArticle($slug);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.articles.index')
|
||||
->with('status', '文章已成功刪除');
|
||||
@@ -313,6 +359,8 @@ class ArticleController extends Controller
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
SiteRevalidationService::revalidateArticle($article->slug);
|
||||
|
||||
return back()->with('status', '文章已成功發布');
|
||||
}
|
||||
|
||||
@@ -339,6 +387,8 @@ class ArticleController extends Controller
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
SiteRevalidationService::revalidateArticle($article->slug);
|
||||
|
||||
return back()->with('status', '文章已成功歸檔');
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\AuditLog;
|
||||
use App\Models\Document;
|
||||
use App\Models\DocumentCategory;
|
||||
use App\Models\DocumentVersion;
|
||||
use App\Support\DownloadFile;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
@@ -238,9 +239,10 @@ class DocumentController extends Controller
|
||||
// Log access
|
||||
$document->logAccess('download', auth()->user());
|
||||
|
||||
return Storage::disk('private')->download(
|
||||
$version->file_path,
|
||||
$version->original_filename
|
||||
return DownloadFile::fromDisk(
|
||||
disk: 'private',
|
||||
path: $version->file_path,
|
||||
downloadName: $version->original_filename
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Page;
|
||||
use App\Services\SiteRevalidationService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PageController extends Controller
|
||||
@@ -72,6 +73,10 @@ class PageController extends Controller
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
if ($validated['status'] === 'published') {
|
||||
SiteRevalidationService::revalidatePage($page->slug);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.show', $page)
|
||||
->with('status', '頁面已成功建立');
|
||||
@@ -135,6 +140,10 @@ class PageController extends Controller
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
if ($page->isPublished()) {
|
||||
SiteRevalidationService::revalidatePage($page->slug);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.show', $page)
|
||||
->with('status', '頁面已成功更新');
|
||||
@@ -147,6 +156,8 @@ class PageController extends Controller
|
||||
}
|
||||
|
||||
$title = $page->title;
|
||||
$slug = $page->slug;
|
||||
$wasPublished = $page->isPublished();
|
||||
$page->delete();
|
||||
|
||||
AuditLog::create([
|
||||
@@ -156,6 +167,10 @@ class PageController extends Controller
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
if ($wasPublished) {
|
||||
SiteRevalidationService::revalidatePage($slug);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.index')
|
||||
->with('status', '頁面已成功刪除');
|
||||
@@ -176,6 +191,8 @@ class PageController extends Controller
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
SiteRevalidationService::revalidatePage($page->slug);
|
||||
|
||||
return back()->with('status', '頁面已成功發布');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,13 @@ class AdminMemberController extends Controller
|
||||
{
|
||||
$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()) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('full_name', '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
|
||||
if (!empty($search)) {
|
||||
@@ -256,7 +257,13 @@ class AdminMemberController extends Controller
|
||||
if ($search = $request->string('search')->toString()) {
|
||||
$query->where(function ($q) use ($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',
|
||||
'Email',
|
||||
'Phone',
|
||||
'Line ID',
|
||||
'Address Line 1',
|
||||
'Address Line 2',
|
||||
'City',
|
||||
@@ -297,6 +305,7 @@ class AdminMemberController extends Controller
|
||||
$member->full_name,
|
||||
$member->email,
|
||||
$member->phone,
|
||||
$member->line_id,
|
||||
$member->address_line_1,
|
||||
$member->address_line_2,
|
||||
$member->city,
|
||||
|
||||
@@ -7,8 +7,8 @@ use App\Http\Resources\ArticleCollectionResource;
|
||||
use App\Http\Resources\ArticleResource;
|
||||
use App\Models\Article;
|
||||
use App\Models\ArticleAttachment;
|
||||
use App\Support\DownloadFile;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ArticleController extends Controller
|
||||
{
|
||||
@@ -87,9 +87,10 @@ class ArticleController extends Controller
|
||||
|
||||
$attachment->incrementDownloadCount();
|
||||
|
||||
return Storage::disk('public')->download(
|
||||
$attachment->file_path,
|
||||
$attachment->original_filename
|
||||
return DownloadFile::fromDisk(
|
||||
disk: 'public',
|
||||
path: $attachment->file_path,
|
||||
downloadName: $attachment->original_filename
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
89
app/Http/Controllers/Api/PublicDocumentController.php
Normal file
89
app/Http/Controllers/Api/PublicDocumentController.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ class MemberDashboardController extends Controller
|
||||
$validated = $request->validate([
|
||||
'full_name' => ['required', 'string', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:20'],
|
||||
'line_id' => ['nullable', 'string', 'max:100'],
|
||||
'national_id' => ['nullable', 'string', 'max:20'],
|
||||
'address_line_1' => ['nullable', 'string', 'max:255'],
|
||||
'address_line_2' => ['nullable', 'string', 'max:255'],
|
||||
@@ -69,6 +70,7 @@ class MemberDashboardController extends Controller
|
||||
'full_name' => $validated['full_name'],
|
||||
'email' => $user->email,
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'line_id' => $validated['line_id'] ?? null,
|
||||
'national_id' => $validated['national_id'] ?? null,
|
||||
'address_line_1' => $validated['address_line_1'] ?? null,
|
||||
'address_line_2' => $validated['address_line_2'] ?? null,
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Models\DocumentCategory;
|
||||
use App\Support\DownloadFile;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
@@ -109,9 +110,10 @@ class PublicDocumentController extends Controller
|
||||
// Log access
|
||||
$document->logAccess('download', $user);
|
||||
|
||||
return Storage::disk('private')->download(
|
||||
$currentVersion->file_path,
|
||||
$currentVersion->original_filename
|
||||
return DownloadFile::fromDisk(
|
||||
disk: 'private',
|
||||
path: $currentVersion->file_path,
|
||||
downloadName: $currentVersion->original_filename
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,9 +141,10 @@ class PublicDocumentController extends Controller
|
||||
// Log access
|
||||
$document->logAccess('download', $user);
|
||||
|
||||
return Storage::disk('private')->download(
|
||||
$version->file_path,
|
||||
$version->original_filename
|
||||
return DownloadFile::fromDisk(
|
||||
disk: 'private',
|
||||
path: $version->file_path,
|
||||
downloadName: $version->original_filename
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ class PublicMemberRegistrationController extends Controller
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email', 'unique:members,email'],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'phone' => ['nullable', 'string', 'max:20'],
|
||||
'line_id' => ['nullable', 'string', 'max:100'],
|
||||
'national_id' => ['nullable', 'string', 'max:20'],
|
||||
'address_line_1' => ['nullable', 'string', 'max:255'],
|
||||
'address_line_2' => ['nullable', 'string', 'max:255'],
|
||||
@@ -57,6 +58,7 @@ class PublicMemberRegistrationController extends Controller
|
||||
'full_name' => $validated['full_name'],
|
||||
'email' => $validated['email'],
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'line_id' => $validated['line_id'] ?? null,
|
||||
'national_id' => $validated['national_id'] ?? null,
|
||||
'address_line_1' => $validated['address_line_1'] ?? null,
|
||||
'address_line_2' => $validated['address_line_2'] ?? null,
|
||||
|
||||
@@ -27,6 +27,7 @@ class StoreMemberRequest extends FormRequest
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'national_id' => ['nullable', 'string', 'max:50'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'line_id' => ['nullable', 'string', 'max:100'],
|
||||
'phone_home' => ['nullable', 'string', 'max:50'],
|
||||
'phone_fax' => ['nullable', 'string', 'max:50'],
|
||||
'birth_date' => ['nullable', 'date'],
|
||||
|
||||
@@ -33,6 +33,7 @@ class UpdateMemberRequest extends FormRequest
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'national_id' => ['nullable', 'string', 'max:50'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'line_id' => ['nullable', 'string', 'max:100'],
|
||||
'phone_home' => ['nullable', 'string', 'max:50'],
|
||||
'phone_fax' => ['nullable', 'string', 'max:50'],
|
||||
'birth_date' => ['nullable', 'date'],
|
||||
|
||||
71
app/Http/Resources/PublicDocumentCollectionResource.php
Normal file
71
app/Http/Resources/PublicDocumentCollectionResource.php
Normal 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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Http/Resources/PublicDocumentResource.php
Normal file
25
app/Http/Resources/PublicDocumentResource.php
Normal 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,
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
31
app/Http/Resources/PublicDocumentVersionResource.php
Normal file
31
app/Http/Resources/PublicDocumentVersionResource.php
Normal 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,
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Jobs/PushNextjsPublicAssetsJob.php
Normal file
32
app/Jobs/PushNextjsPublicAssetsJob.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Article extends Model
|
||||
@@ -47,6 +48,7 @@ class Article extends Model
|
||||
'status',
|
||||
'access_level',
|
||||
'featured_image_path',
|
||||
'featured_image_storage_path',
|
||||
'featured_image_alt',
|
||||
'author_name',
|
||||
'author_user_id',
|
||||
@@ -447,7 +449,12 @@ class Article extends Model
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ class Member extends Model
|
||||
'full_name',
|
||||
'email',
|
||||
'phone',
|
||||
'line_id',
|
||||
'phone_home',
|
||||
'phone_fax',
|
||||
'address_line_1',
|
||||
|
||||
54
app/Observers/DocumentObserver.php
Normal file
54
app/Observers/DocumentObserver.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
111
app/Services/NextjsRepoSyncService.php
Normal file
111
app/Services/NextjsRepoSyncService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
101
app/Services/SiteAssetSyncService.php
Normal file
101
app/Services/SiteAssetSyncService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
60
app/Services/SiteRevalidationService.php
Normal file
60
app/Services/SiteRevalidationService.php
Normal 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()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
71
app/Support/DownloadFile.php
Normal file
71
app/Support/DownloadFile.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,13 @@ return [
|
||||
'throw' => false,
|
||||
],
|
||||
|
||||
// Private files remain under storage/app and are served via controller responses.
|
||||
'private' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app'),
|
||||
'throw' => false,
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
|
||||
@@ -31,4 +31,21 @@ return [
|
||||
'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'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
28
database/factories/ArticleCategoryFactory.php
Normal file
28
database/factories/ArticleCategoryFactory.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
79
database/factories/ArticleFactory.php
Normal file
79
database/factories/ArticleFactory.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
26
database/factories/ArticleTagFactory.php
Normal file
26
database/factories/ArticleTagFactory.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
47
database/factories/PageFactory.php
Normal file
47
database/factories/PageFactory.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -169,7 +169,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 sm:grid-cols-2">
|
||||
<div class="grid gap-6 sm:grid-cols-3">
|
||||
<div>
|
||||
<label for="phone" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
行動電話
|
||||
@@ -186,6 +186,22 @@
|
||||
@enderror
|
||||
</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>
|
||||
<label for="phone_home" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
室內電話
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 sm:grid-cols-2">
|
||||
<div class="grid gap-6 sm:grid-cols-3">
|
||||
<div>
|
||||
<label for="phone" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
行動電話
|
||||
@@ -184,6 +184,22 @@
|
||||
@enderror
|
||||
</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>
|
||||
<label for="phone_home" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
室內電話
|
||||
|
||||
@@ -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">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">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_fax</code> (optional)</li>
|
||||
<li><code class="text-gray-800 dark:text-gray-200">birth_date</code> (YYYY-MM-DD, optional)</li>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<form method="GET" action="{{ route('admin.members.index') }}" class="mb-4 space-y-4" role="search" aria-label="搜尋和篩選會員">
|
||||
<div>
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
依姓名、電子郵件、電話或身分證號搜尋
|
||||
依姓名、電子郵件、電話、Line ID 或身分證號搜尋
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -23,7 +23,7 @@
|
||||
placeholder="輸入搜尋關鍵字..."
|
||||
>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
在姓名、電子郵件、電話號碼和身分證號中搜尋
|
||||
在姓名、電子郵件、電話號碼、Line ID 和身分證號中搜尋
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -103,6 +103,15 @@
|
||||
</dd>
|
||||
</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">
|
||||
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
室內電話/傳真
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Settings Dropdown -->
|
||||
@auth
|
||||
<div class="hidden sm:flex sm:items-center sm:ms-6">
|
||||
<x-dropdown align="right" width="48">
|
||||
<x-slot name="trigger">
|
||||
@@ -159,6 +160,13 @@
|
||||
</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 -->
|
||||
<div class="-me-2 flex items-center sm:hidden">
|
||||
@@ -279,6 +287,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Responsive Settings Options -->
|
||||
@auth
|
||||
<div class="pt-4 pb-1 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="px-4">
|
||||
<div class="font-medium text-base text-gray-800 dark:text-slate-200">{{ Auth::user()->name }}</div>
|
||||
@@ -302,5 +311,14 @@
|
||||
</form>
|
||||
</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>
|
||||
</nav>
|
||||
|
||||
@@ -34,6 +34,13 @@
|
||||
<x-input-error :messages="$errors->get('phone')" class="mt-2" />
|
||||
</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) -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="national_id" :value="__('National ID (Optional)')" />
|
||||
|
||||
@@ -92,6 +92,15 @@
|
||||
</dd>
|
||||
</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">
|
||||
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ __('Membership Type') }}
|
||||
|
||||
@@ -50,6 +50,13 @@
|
||||
<x-input-error :messages="$errors->get('phone')" class="mt-2" />
|
||||
</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) -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="national_id" :value="__('National ID (Optional)')" />
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
55
tests/Feature/Cms/AdminCmsAccessTest.php
Normal file
55
tests/Feature/Cms/AdminCmsAccessTest.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
91
tests/Feature/Cms/ArticleApiTest.php
Normal file
91
tests/Feature/Cms/ArticleApiTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
51
tests/Feature/Cms/HomepageApiTest.php
Normal file
51
tests/Feature/Cms/HomepageApiTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
171
tests/Feature/Cms/ImportArticleDocumentsCommandTest.php
Normal file
171
tests/Feature/Cms/ImportArticleDocumentsCommandTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
131
tests/Feature/Cms/PublicDocumentApiTest.php
Normal file
131
tests/Feature/Cms/PublicDocumentApiTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -239,4 +239,44 @@ class DocumentTest extends TestCase
|
||||
$document->refresh();
|
||||
$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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ class MemberRegistrationTest extends TestCase
|
||||
'password' => 'Password123!',
|
||||
'password_confirmation' => 'Password123!',
|
||||
'phone' => '0912345678',
|
||||
'line_id' => 'john_doe_line',
|
||||
'address_line_1' => '123 Test St',
|
||||
'city' => 'Taipei',
|
||||
'postal_code' => '100',
|
||||
@@ -62,6 +63,7 @@ class MemberRegistrationTest extends TestCase
|
||||
'full_name' => 'John Doe',
|
||||
'email' => 'john@example.com',
|
||||
'phone' => '0912345678',
|
||||
'line_id' => 'john_doe_line',
|
||||
'membership_status' => Member::STATUS_PENDING,
|
||||
]);
|
||||
}
|
||||
@@ -256,6 +258,7 @@ class MemberRegistrationTest extends TestCase
|
||||
|
||||
$member = Member::where('email', 'john@example.com')->first();
|
||||
$this->assertNull($member->phone);
|
||||
$this->assertNull($member->line_id);
|
||||
$this->assertNull($member->address_line_1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ trait CreatesMemberData
|
||||
'password' => 'Password123!',
|
||||
'password_confirmation' => 'Password123!',
|
||||
'phone' => '0912345678',
|
||||
'line_id' => 'line.member.test',
|
||||
'national_id' => 'A123456789',
|
||||
'address_line_1' => '123 Test Street',
|
||||
'address_line_2' => '',
|
||||
|
||||
28
tests/Unit/DownloadFileTest.php
Normal file
28
tests/Unit/DownloadFileTest.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user