Initial commit

This commit is contained in:
2025-11-20 23:21:05 +08:00
commit 13bc6db529
378 changed files with 54527 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Console\Commands;
use App\Models\AuditLog;
use App\Models\Document;
use Illuminate\Console\Command;
class ArchiveExpiredDocuments extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'documents:archive-expired
{--dry-run : Preview which documents would be archived}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Automatically archive documents that have passed their expiration date';
/**
* Execute the console command.
*/
public function handle()
{
$dryRun = $this->option('dry-run');
// Check if auto-archive is enabled in system settings
$settings = app(\App\Services\SettingsService::class);
if (!$settings->isAutoArchiveEnabled()) {
$this->info('Auto-archive is disabled in system settings.');
return 0;
}
// Find expired documents that should be auto-archived
$expiredDocuments = Document::where('status', 'active')
->where('auto_archive_on_expiry', true)
->whereNotNull('expires_at')
->whereDate('expires_at', '<', now())
->get();
if ($expiredDocuments->isEmpty()) {
$this->info('No expired documents found.');
return 0;
}
$this->info("Found {$expiredDocuments->count()} expired document(s)");
if ($dryRun) {
$this->warn('DRY RUN - No changes will be made');
$this->newLine();
}
foreach ($expiredDocuments as $document) {
$this->line("- {$document->title} (expired: {$document->expires_at->format('Y-m-d')})");
if (!$dryRun) {
$document->archive();
AuditLog::create([
'user_id' => null,
'action' => 'document.auto_archived',
'auditable_type' => Document::class,
'auditable_id' => $document->id,
'old_values' => ['status' => 'active'],
'new_values' => ['status' => 'archived'],
'description' => "Document auto-archived due to expiration on {$document->expires_at->format('Y-m-d')}",
'ip_address' => '127.0.0.1',
'user_agent' => 'CLI Auto-Archive',
]);
$this->info(" ✓ Archived");
}
}
$this->newLine();
if (!$dryRun) {
$this->info("Successfully archived {$expiredDocuments->count()} document(s)");
}
return 0;
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Spatie\Permission\Models\Role;
class AssignRole extends Command
{
protected $signature = 'roles:assign {email} {role}';
protected $description = 'Assign a role to a user by email';
public function handle(): int
{
$email = $this->argument('email');
$roleName = $this->argument('role');
$user = User::where('email', $email)->first();
if (! $user) {
$this->error("User not found for email {$email}");
return static::FAILURE;
}
$role = Role::firstOrCreate(['name' => $roleName, 'guard_name' => 'web']);
$user->assignRole($role);
$this->info("Assigned role {$roleName} to {$email}");
return static::SUCCESS;
}
}

View File

@@ -0,0 +1,189 @@
<?php
namespace App\Console\Commands;
use App\Models\AuditLog;
use App\Models\Document;
use App\Models\DocumentCategory;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
class ImportDocuments extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'documents:import
{path : Path to directory containing documents and manifest.json}
{--user-id=1 : User ID to attribute uploads to}
{--dry-run : Preview import without making changes}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Bulk import documents from a directory with manifest.json';
/**
* Execute the console command.
*/
public function handle()
{
$path = $this->argument('path');
$userId = $this->option('user-id');
$dryRun = $this->option('dry-run');
// Validate path
if (!File::isDirectory($path)) {
$this->error("Directory not found: {$path}");
return 1;
}
// Check for manifest.json
$manifestPath = $path . '/manifest.json';
if (!File::exists($manifestPath)) {
$this->error("manifest.json not found in {$path}");
$this->info("Expected format:");
$this->line($this->getManifestExample());
return 1;
}
// Load manifest
$manifest = json_decode(File::get($manifestPath), true);
if (!$manifest || !isset($manifest['documents'])) {
$this->error("Invalid manifest.json format");
return 1;
}
// Validate user
$user = User::find($userId);
if (!$user) {
$this->error("User not found: {$userId}");
return 1;
}
$this->info("Importing documents from: {$path}");
$this->info("Attributed to: {$user->name}");
if ($dryRun) {
$this->warn("DRY RUN - No changes will be made");
}
$this->newLine();
$successCount = 0;
$errorCount = 0;
foreach ($manifest['documents'] as $item) {
try {
$this->processDocument($path, $item, $user, $dryRun);
$successCount++;
} catch (\Exception $e) {
$this->error("Error processing {$item['file']}: {$e->getMessage()}");
$errorCount++;
}
}
$this->newLine();
$this->info("Import complete!");
$this->info("Success: {$successCount}");
if ($errorCount > 0) {
$this->error("Errors: {$errorCount}");
}
return 0;
}
protected function processDocument(string $basePath, array $item, User $user, bool $dryRun): void
{
$filePath = $basePath . '/' . $item['file'];
// Validate file exists
if (!File::exists($filePath)) {
throw new \Exception("File not found: {$filePath}");
}
// Find or create category
$category = DocumentCategory::where('slug', $item['category'])->first();
if (!$category) {
throw new \Exception("Category not found: {$item['category']}");
}
$this->line("Processing: {$item['title']}");
$this->line(" Category: {$category->name}");
$this->line(" File: {$item['file']}");
if ($dryRun) {
$this->line(" [DRY RUN] Would create document");
return;
}
// Copy file to storage
$fileInfo = pathinfo($filePath);
$storagePath = 'documents/' . uniqid() . '.' . $fileInfo['extension'];
Storage::disk('private')->put($storagePath, File::get($filePath));
// Create document
$document = Document::create([
'document_category_id' => $category->id,
'title' => $item['title'],
'document_number' => $item['document_number'] ?? null,
'description' => $item['description'] ?? null,
'access_level' => $item['access_level'] ?? $category->default_access_level,
'status' => 'active',
'created_by' => $user->id,
'updated_by' => $user->id,
]);
// Add first version
$document->addVersion(
filePath: $storagePath,
originalFilename: $fileInfo['basename'],
mimeType: File::mimeType($filePath),
fileSize: File::size($filePath),
uploadedBy: $user,
versionNotes: $item['version_notes'] ?? 'Initial import'
);
AuditLog::create([
'user_id' => $user->id,
'action' => 'document.imported',
'auditable_type' => Document::class,
'auditable_id' => $document->id,
'old_values' => null,
'new_values' => ['title' => $item['title']],
'ip_address' => '127.0.0.1',
'user_agent' => 'CLI Import',
]);
$this->info(" ✓ Created document ID: {$document->id}");
}
protected function getManifestExample(): string
{
return <<<'JSON'
{
"documents": [
{
"file": "bylaws.pdf",
"title": "協會章程",
"category": "association-bylaws",
"document_number": "2024-001",
"description": "協會章程修正版",
"access_level": "members",
"version_notes": "Initial import"
},
{
"file": "meeting-2024-01.pdf",
"title": "2024年1月會議記錄",
"category": "meeting-minutes",
"access_level": "members"
}
]
}
JSON;
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace App\Console\Commands;
use App\Mail\MemberActivationMail;
use App\Models\Member;
use App\Models\User;
use App\Support\AuditLogger;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
class ImportMembers extends Command
{
protected $signature = 'members:import {path : CSV file path}';
protected $description = 'Import members from a CSV file';
public function handle(): int
{
$path = $this->argument('path');
if (! is_file($path)) {
$this->error("File not found: {$path}");
return static::FAILURE;
}
$handle = fopen($path, 'r');
if (! $handle) {
$this->error("Unable to open file: {$path}");
return static::FAILURE;
}
$header = fgetcsv($handle);
if (! $header) {
$this->error('CSV file is empty.');
fclose($handle);
return static::FAILURE;
}
$header = array_map('trim', $header);
$expected = [
'full_name',
'email',
'phone',
'address_line_1',
'address_line_2',
'city',
'postal_code',
'emergency_contact_name',
'emergency_contact_phone',
'membership_started_at',
'membership_expires_at',
];
foreach ($expected as $column) {
if (! in_array($column, $header, true)) {
$this->error("Missing required column: {$column}");
fclose($handle);
return static::FAILURE;
}
}
$indexes = array_flip($header);
$createdUsers = 0;
$updatedMembers = 0;
while (($row = fgetcsv($handle)) !== false) {
$email = trim($row[$indexes['email']] ?? '');
if ($email === '') {
continue;
}
$fullName = trim($row[$indexes['full_name']] ?? '');
$nationalId = trim($row[$indexes['national_id']] ?? '');
$phone = trim($row[$indexes['phone']] ?? '');
$started = trim($row[$indexes['membership_started_at']] ?? '');
$expires = trim($row[$indexes['membership_expires_at']] ?? '');
$address1 = trim($row[$indexes['address_line_1']] ?? '');
$address2 = trim($row[$indexes['address_line_2']] ?? '');
$city = trim($row[$indexes['city']] ?? '');
$postal = trim($row[$indexes['postal_code']] ?? '');
$emergencyName = trim($row[$indexes['emergency_contact_name']] ?? '');
$emergencyPhone = trim($row[$indexes['emergency_contact_phone']] ?? '');
$user = User::where('email', $email)->first();
$isNewUser = false;
if (! $user) {
$user = User::create([
'name' => $fullName !== '' ? $fullName : $email,
'email' => $email,
'password' => Str::random(32),
]);
$isNewUser = true;
$createdUsers++;
}
$member = Member::updateOrCreate(
['user_id' => $user->id],
[
'full_name' => $fullName !== '' ? $fullName : $user->name,
'email' => $email,
'national_id' => $nationalId !== '' ? $nationalId : null,
'phone' => $phone !== '' ? $phone : null,
'address_line_1' => $address1 ?: null,
'address_line_2' => $address2 ?: null,
'city' => $city ?: null,
'postal_code' => $postal ?: null,
'emergency_contact_name' => $emergencyName ?: null,
'emergency_contact_phone' => $emergencyPhone ?: null,
'membership_started_at' => $started !== '' ? $started : null,
'membership_expires_at' => $expires !== '' ? $expires : null,
],
);
$updatedMembers++;
if ($isNewUser) {
$token = Password::createToken($user);
Mail::to($user)->queue(new MemberActivationMail($user, $token));
AuditLogger::log('user.activation_link_sent', $user, [
'email' => $user->email,
]);
}
}
fclose($handle);
$this->info("Users created: {$createdUsers}");
$this->info("Members imported/updated: {$updatedMembers}");
return static::SUCCESS;
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Console\Commands;
use App\Mail\MembershipExpiryReminderMail;
use App\Models\Member;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class SendMembershipExpiryReminders extends Command
{
protected $signature = 'members:send-expiry-reminders {--days=30 : Number of days before expiry to send reminders}';
protected $description = 'Send membership expiry reminder emails';
public function handle(): int
{
$days = (int) $this->option('days');
$targetDate = now()->addDays($days)->toDateString();
$members = Member::whereDate('membership_expires_at', $targetDate)
->where(function ($q) {
$q->whereNull('last_expiry_reminder_sent_at')
->orWhere('last_expiry_reminder_sent_at', '<', now()->subDays(1));
})
->get();
if ($members->isEmpty()) {
$this->info('No members to remind.');
return static::SUCCESS;
}
foreach ($members as $member) {
if (! $member->email) {
continue;
}
Mail::to($member->email)->queue(new MembershipExpiryReminderMail($member));
$member->last_expiry_reminder_sent_at = now();
$member->save();
}
$this->info('Reminders sent to '.$members->count().' member(s).');
return static::SUCCESS;
}
}