Add file attachments to weekly hours and timesheet view

Employees can now attach supporting files (PDFs, images, office docs)
to a project row on the Weekly hours page and view/download them when
viewing a timesheet.

- New timesheet_attachments table + TimesheetAttachment model with
  automatic file cleanup on delete and on parent timesheet deletion
- Weekly hours grid: per-row upload/remove controls for editable saved
  rows, with existing attachments shown as downloadable chips
- ViewTimesheet infolist: new Attachments section with download links
- Authenticated download route authorized via TimesheetAccess so only
  users who can view a timesheet can fetch its files
- Uploads validated for type (pdf/images/doc/xls/csv/txt) and size (10MB)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cwd7t8ySm9Kegxxfefmm69
This commit is contained in:
Claude 2026-07-13 15:16:11 +00:00
parent da5f5d5770
commit 02e36d7a9b
No known key found for this signature in database
11 changed files with 574 additions and 16 deletions

View File

@ -2,6 +2,8 @@
namespace App\Filament\Pages;
use App\Models\Timesheet;
use App\Models\TimesheetAttachment;
use App\Models\User;
use App\Support\TimesheetAccess;
use App\Support\WeeklyHoursAccess;
@ -12,9 +14,14 @@ use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Validation\ValidationException;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
class WeeklyHours extends Page
{
use WithFileUploads;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-calendar-days';
protected static string|\UnitEnum|null $navigationGroup = 'Time Tracking';
@ -36,6 +43,14 @@ class WeeklyHours extends Page
/** @var list<array<string, mixed>> */
public array $rows = [];
/**
* Pending file uploads keyed by row index. Livewire streams the selected
* file here before {@see uploadAttachment()} persists it.
*
* @var array<int, TemporaryUploadedFile>
*/
public array $rowUploads = [];
public function mount(): void
{
$this->weekStart = now()->startOfWeek(Carbon::MONDAY)->format('Y-m-d');
@ -78,7 +93,7 @@ class WeeklyHours extends Page
{
$start = Carbon::parse($this->weekStart);
return $start->format('F Y') . ' Week ' . $start->isoWeek();
return $start->format('F Y').' Week '.$start->isoWeek();
}
/**
@ -189,6 +204,7 @@ class WeeklyHours extends Page
'overtime_hours' => [0, 0, 0, 0, 0, 0, 0],
'status' => 'draft',
'editable' => true,
'attachments' => [],
];
}
@ -210,6 +226,105 @@ class WeeklyHours extends Page
}
}
/**
* Persist the file staged in {@see $rowUploads} for the given row. The row
* must already be saved (have an id) and be editable by the current user.
*/
public function uploadAttachment(int $rowIndex): void
{
if (! $this->canEditSheet()) {
return;
}
$file = $this->rowUploads[$rowIndex] ?? null;
$row = $this->rows[$rowIndex] ?? null;
if (! $file || ! $row || ! ($row['editable'] ?? false)) {
return;
}
$timesheetId = $row['id'] ?? null;
if (! $timesheetId) {
Notification::make()
->title('Save the row before attaching files.')
->warning()
->send();
return;
}
$this->validate([
"rowUploads.{$rowIndex}" => ['file', 'max:10240', 'mimes:pdf,jpg,jpeg,png,gif,webp,doc,docx,xls,xlsx,csv,txt'],
], attributes: ["rowUploads.{$rowIndex}" => 'attachment']);
$this->authorizeSelectedUser();
$timesheet = Timesheet::query()
->where('user_id', $this->selectedUserId)
->find($timesheetId);
if (! $timesheet || ! TimesheetAccess::userCanEditTimesheet(auth()->user(), $timesheet)) {
Notification::make()
->title('This row can no longer be edited.')
->danger()
->send();
return;
}
$path = $file->store("timesheet-attachments/{$timesheet->id}", 'local');
$timesheet->attachments()->create([
'uploaded_by' => auth()->id(),
'disk' => 'local',
'path' => $path,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
]);
unset($this->rowUploads[$rowIndex]);
$this->loadSheet();
Notification::make()
->title('Attachment uploaded')
->success()
->send();
}
public function removeAttachment(int $attachmentId): void
{
if (! $this->canEditSheet()) {
return;
}
$this->authorizeSelectedUser();
$attachment = TimesheetAttachment::query()
->with('timesheet')
->whereHas('timesheet', fn ($query) => $query->where('user_id', $this->selectedUserId))
->find($attachmentId);
if (! $attachment || ! $attachment->timesheet
|| ! TimesheetAccess::userCanEditTimesheet(auth()->user(), $attachment->timesheet)) {
return;
}
$attachment->delete();
$this->loadSheet();
Notification::make()
->title('Attachment removed')
->success()
->send();
}
public function attachmentDownloadUrl(int $attachmentId): string
{
return route('timesheet-attachments.download', ['attachment' => $attachmentId]);
}
public function save(): void
{
if (! $this->canEditSheet()) {
@ -225,7 +340,7 @@ class WeeklyHours extends Page
try {
$this->sheet()->saveRows($this->rows, auth()->user());
} catch (\Illuminate\Validation\ValidationException $exception) {
} catch (ValidationException $exception) {
Notification::make()
->title('Could not save weekly hours')
->body($this->formatValidationError($exception))
@ -244,7 +359,7 @@ class WeeklyHours extends Page
->send();
}
public function formatHours(float | int | string | null $hours): string
public function formatHours(float|int|string|null $hours): string
{
return WeeklyHoursFormatter::display($hours);
}
@ -341,7 +456,7 @@ class WeeklyHours extends Page
$target = $this->selectedUser();
if (! $viewer) {
throw new AuthorizationException();
throw new AuthorizationException;
}
if ($viewer->isEmployee() && $viewer->id !== $target->id) {
@ -369,7 +484,7 @@ class WeeklyHours extends Page
}
}
protected function formatValidationError(\Illuminate\Validation\ValidationException $exception): string
protected function formatValidationError(ValidationException $exception): string
{
return collect($exception->errors())
->flatMap(function (array $messages, string $key): array {

View File

@ -8,6 +8,7 @@ use App\Support\ProjectDisplay;
use App\Support\TimesheetAccess;
use Filament\Actions;
use Filament\Forms;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Pages\ViewRecord;
@ -87,7 +88,7 @@ class ViewTimesheet extends ViewRecord
->date('d/m/Y'),
TextEntry::make('week_number')
->label('Week Number')
->getStateUsing(fn (Model $record) => 'Week ' . $record->week_start->isoWeek() . ', ' . $record->week_start->year),
->getStateUsing(fn (Model $record) => 'Week '.$record->week_start->isoWeek().', '.$record->week_start->year),
TextEntry::make('status')
->badge()
->color(fn (string $state) => match ($state) {
@ -102,7 +103,7 @@ class ViewTimesheet extends ViewRecord
Section::make('Approval History')
->icon('heroicon-o-clipboard-document-check')
->schema([
\Filament\Infolists\Components\RepeatableEntry::make('approvalLogs')
RepeatableEntry::make('approvalLogs')
->schema([
TextEntry::make('approver_name')
->label('By')
@ -134,6 +135,15 @@ class ViewTimesheet extends ViewRecord
->hiddenLabel(),
])
->visible(fn (Model $record) => filled($record->notes)),
Section::make('Attachments')
->icon('heroicon-o-paper-clip')
->schema([
ViewEntry::make('attachments')
->hiddenLabel()
->view('filament.infolists.timesheet-attachments'),
])
->visible(fn (Model $record) => $record->attachments()->exists()),
])->grow(),
Group::make([

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers;
use App\Models\TimesheetAttachment;
use App\Support\TimesheetAccess;
use Illuminate\Support\Facades\Storage;
class TimesheetAttachmentController extends Controller
{
public function download(TimesheetAttachment $attachment)
{
$user = auth()->user();
$attachment->loadMissing('timesheet.project');
if (! $user || ! $attachment->timesheet
|| ! TimesheetAccess::userCanViewTimesheet($user, $attachment->timesheet)) {
abort(403);
}
$disk = $attachment->disk ?: 'local';
if (! $attachment->path || ! Storage::disk($disk)->exists($attachment->path)) {
abort(404);
}
return Storage::disk($disk)->download($attachment->path, $attachment->original_name);
}
}

View File

@ -31,6 +31,16 @@ class Timesheet extends Model
];
}
protected static function booted(): void
{
// Ensure attachment files are removed with the timesheet. Deleting each
// attachment through the model (rather than relying on the database
// cascade) fires TimesheetAttachment's own cleanup that unlinks files.
static::deleting(function (Timesheet $timesheet): void {
$timesheet->attachments()->get()->each->delete();
});
}
public function user()
{
return $this->belongsTo(User::class);
@ -46,6 +56,11 @@ class Timesheet extends Model
return $this->hasMany(ApprovalLog::class);
}
public function attachments()
{
return $this->hasMany(TimesheetAttachment::class)->latest('id');
}
public function latestApprovalLog(string $action): ?ApprovalLog
{
if ($this->relationLoaded('approvalLogs')) {
@ -100,22 +115,22 @@ class Timesheet extends Model
public function totalRegularHours(): float
{
$hours = $this->hours;
if (! is_array($hours)) {
return 0.0;
}
return array_sum($hours);
}
public function totalOvertimeHours(): float
{
$overtimeHours = $this->overtime_hours;
if (! is_array($overtimeHours)) {
return 0.0;
}
return array_sum($overtimeHours);
}
@ -211,7 +226,7 @@ class Timesheet extends Model
if (! $this->relationLoaded('project')) {
$this->load('project');
}
$project = $this->project;
if ($this->isPendingPm()) {

View File

@ -0,0 +1,69 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class TimesheetAttachment extends Model
{
protected $fillable = [
'timesheet_id',
'uploaded_by',
'disk',
'path',
'original_name',
'mime_type',
'size',
];
protected function casts(): array
{
return [
'size' => 'integer',
];
}
protected static function booted(): void
{
// Remove the underlying file whenever the record is deleted, whether
// that happens directly or as part of a timesheet being removed.
static::deleting(function (TimesheetAttachment $attachment): void {
$disk = $attachment->disk ?: 'local';
if ($attachment->path && Storage::disk($disk)->exists($attachment->path)) {
Storage::disk($disk)->delete($attachment->path);
}
});
}
public function timesheet()
{
return $this->belongsTo(Timesheet::class);
}
public function uploader()
{
return $this->belongsTo(User::class, 'uploaded_by');
}
public function humanSize(): string
{
$size = (int) $this->size;
if ($size <= 0) {
return '—';
}
$units = ['B', 'KB', 'MB', 'GB'];
$power = min((int) floor(log($size, 1024)), count($units) - 1);
$value = $size / (1024 ** $power);
return ($power === 0 ? $size : number_format($value, 1)).' '.$units[$power];
}
public function isImage(): bool
{
return str_starts_with((string) $this->mime_type, 'image/');
}
}

View File

@ -34,7 +34,7 @@ class WeeklyHoursSheet
public function loadRows(): array
{
$timesheets = Timesheet::query()
->with('project')
->with(['project', 'attachments'])
->where('user_id', $this->user->id)
->whereDate('week_start', $this->weekStart->toDateString())
->orderBy('id')
@ -263,8 +263,8 @@ class WeeklyHoursSheet
],
[
'project_id' => [new ProjectMembershipForEmployee($this->user, $existing)],
'hours' => [new ValidDailyHours()],
'overtime_hours' => [new ValidDailyHours()],
'hours' => [new ValidDailyHours],
'overtime_hours' => [new ValidDailyHours],
],
);
@ -375,6 +375,11 @@ class WeeklyHoursSheet
),
'status' => $timesheet->status,
'editable' => $timesheet->isEditable(),
'attachments' => $timesheet->attachments->map(fn ($attachment): array => [
'id' => $attachment->id,
'name' => $attachment->original_name,
'size' => $attachment->humanSize(),
])->all(),
];
}
@ -401,6 +406,7 @@ class WeeklyHoursSheet
'overtime_hours' => [0, 0, 0, 0, 0, 0, 0],
'status' => 'draft',
'editable' => true,
'attachments' => [],
];
}

View File

@ -0,0 +1,30 @@
<?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::create('timesheet_attachments', function (Blueprint $table) {
$table->id();
$table->foreignId('timesheet_id')->constrained()->cascadeOnDelete();
$table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete();
$table->string('disk', 40)->default('local');
$table->string('path');
$table->string('original_name');
$table->string('mime_type')->nullable();
$table->unsignedBigInteger('size')->nullable();
$table->timestamps();
$table->index('timesheet_id');
});
}
public function down(): void
{
Schema::dropIfExists('timesheet_attachments');
}
};

View File

@ -0,0 +1,22 @@
@php
$attachments = $entry->getRecord()->attachments;
@endphp
<div class="space-y-2">
@forelse ($attachments as $attachment)
<a
href="{{ route('timesheet-attachments.download', ['attachment' => $attachment->id]) }}"
target="_blank"
rel="noopener"
class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 px-3 py-2 text-sm transition hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
>
<span class="flex min-w-0 items-center gap-2">
<x-filament::icon icon="heroicon-o-paper-clip" class="h-4 w-4 shrink-0 text-gray-400" />
<span class="truncate font-medium text-primary-600 dark:text-primary-400">{{ $attachment->original_name }}</span>
</span>
<span class="shrink-0 text-xs text-gray-500 dark:text-gray-400">{{ $attachment->humanSize() }}</span>
</a>
@empty
<p class="text-sm italic text-gray-400">No attachments.</p>
@endforelse
</div>

View File

@ -7,6 +7,25 @@
.corp-weekly-hours-ot-row .corp-weekly-hours-hour-input { border-color: rgb(253 230 138 / 0.8); background: rgb(255 251 235 / 0.5); }
.corp-weekly-hours-total-row-ot td { background: rgb(255 251 235 / 0.8); font-weight: 600; }
.dark .corp-weekly-hours-total-row-ot td { background: rgb(69 26 3 / 0.3); }
.corp-weekly-hours-attach-row td { border-top: 0; background: rgb(248 250 252 / 0.7); }
.dark .corp-weekly-hours-attach-row td { background: rgb(30 41 59 / 0.3); }
.corp-weekly-hours-attach-label { font-size: 0.75rem; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: rgb(100 116 139); }
.dark .corp-weekly-hours-attach-label { color: rgb(148 163 184); }
.corp-weekly-hours-attachments { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
.corp-weekly-hours-attach-chip { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.2rem 0.5rem; border-radius: 9999px; background: rgb(226 232 240 / 0.7); font-size: 0.8rem; }
.dark .corp-weekly-hours-attach-chip { background: rgb(51 65 85 / 0.6); }
.corp-weekly-hours-attach-chip a { color: rgb(37 99 235); text-decoration: none; font-weight: 500; }
.corp-weekly-hours-attach-chip a:hover { text-decoration: underline; }
.dark .corp-weekly-hours-attach-chip a { color: rgb(96 165 250); }
.corp-weekly-hours-attach-size { color: rgb(100 116 139); font-size: 0.7rem; }
.corp-weekly-hours-attach-remove { display: inline-flex; color: rgb(148 163 184); }
.corp-weekly-hours-attach-remove:hover { color: rgb(220 38 38); }
.corp-weekly-hours-attach-empty, .corp-weekly-hours-attach-hint { color: rgb(148 163 184); font-size: 0.8rem; font-style: italic; }
.corp-weekly-hours-attach-upload { display: inline-flex; align-items: center; gap: 0.5rem; }
.corp-weekly-hours-attach-input { font-size: 0.75rem; max-width: 15rem; }
.corp-weekly-hours-attach-btn { padding: 0.2rem 0.6rem; border-radius: 0.375rem; background: rgb(37 99 235); color: #fff; font-size: 0.75rem; font-weight: 600; }
.corp-weekly-hours-attach-btn:disabled { opacity: 0.5; }
.corp-weekly-hours-attach-error { margin-top: 0.35rem; color: rgb(220 38 38); font-size: 0.75rem; }
</style>
<div class="corp-weekly-hours">
<div class="corp-weekly-hours-toolbar">
@ -112,7 +131,7 @@
{{ $this->rowDuration($row) }}
</td>
@if ($this->canEditSheet())
<td class="corp-weekly-hours-actions" rowspan="2">
<td class="corp-weekly-hours-actions" rowspan="3">
@if ($editable)
<button
type="button"
@ -153,6 +172,68 @@
{{ $this->rowOvertimeDuration($row) }}
</td>
</tr>
@php
$attachments = $row['attachments'] ?? [];
@endphp
<tr
wire:key="weekly-hours-attach-row-{{ $row['id'] ?? 'new-' . $rowIndex }}"
class="corp-weekly-hours-attach-row"
>
<td class="corp-weekly-hours-attach-label">Files</td>
<td colspan="9">
<div class="corp-weekly-hours-attachments">
@forelse ($attachments as $attachment)
<span class="corp-weekly-hours-attach-chip">
<x-filament::icon icon="heroicon-m-paper-clip" class="h-3.5 w-3.5" />
<a href="{{ $this->attachmentDownloadUrl($attachment['id']) }}" target="_blank" rel="noopener">
{{ $attachment['name'] }}
</a>
<span class="corp-weekly-hours-attach-size">{{ $attachment['size'] }}</span>
@if ($editable)
<button
type="button"
wire:click="removeAttachment({{ $attachment['id'] }})"
wire:confirm="Remove this attachment?"
class="corp-weekly-hours-attach-remove"
aria-label="Remove attachment"
>
<x-filament::icon icon="heroicon-m-x-mark" class="h-3 w-3" />
</button>
@endif
</span>
@empty
<span class="corp-weekly-hours-attach-empty">No files attached</span>
@endforelse
@if ($editable)
@if ($row['id'] ?? null)
<span class="corp-weekly-hours-attach-upload">
<input
type="file"
wire:model="rowUploads.{{ $rowIndex }}"
class="corp-weekly-hours-attach-input"
/>
<span wire:loading wire:target="rowUploads.{{ $rowIndex }}" class="corp-weekly-hours-attach-hint">Uploading…</span>
<button
type="button"
wire:click="uploadAttachment({{ $rowIndex }})"
wire:loading.attr="disabled"
wire:target="rowUploads.{{ $rowIndex }},uploadAttachment"
class="corp-weekly-hours-attach-btn"
>
Attach
</button>
</span>
@else
<span class="corp-weekly-hours-attach-hint">Save the row to attach files.</span>
@endif
@endif
</div>
@error('rowUploads.' . $rowIndex)
<div class="corp-weekly-hours-attach-error">{{ $message }}</div>
@enderror
</td>
</tr>
@endforeach
</tbody>
<tfoot>

View File

@ -3,6 +3,7 @@
use App\Http\Controllers\Auth\SetPasswordController;
use App\Http\Controllers\FaviconController;
use App\Http\Controllers\PdfController;
use App\Http\Controllers\TimesheetAttachmentController;
use App\Http\Controllers\UptimeHeartbeatController;
use Illuminate\Support\Facades\Route;
@ -56,6 +57,8 @@ Route::middleware(['auth'])->group(function () {
->where('weekStart', '[0-9]{4}-[0-9]{2}-[0-9]{2}')
->name('weekly-hours.print');
Route::get('/pdf/summary', [PdfController::class, 'summary'])->name('pdf.summary');
Route::get('/timesheet-attachments/{attachment}/download', [TimesheetAttachmentController::class, 'download'])
->name('timesheet-attachments.download');
});
Route::middleware('throttle:60,1')->group(function () {

View File

@ -0,0 +1,178 @@
<?php
namespace Tests\Feature;
use App\Filament\Pages\WeeklyHours;
use App\Models\Project;
use App\Models\Timesheet;
use App\Models\TimesheetAttachment;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class TimesheetAttachmentTest extends TestCase
{
use RefreshDatabase;
private User $employee;
private Project $project;
private Carbon $monday;
protected function setUp(): void
{
parent::setUp();
Carbon::setTestNow(Carbon::parse('2026-06-24 09:00:00'));
Storage::fake('local');
$this->monday = Carbon::now()->startOfWeek(Carbon::MONDAY);
$this->employee = User::factory()->create(['role' => 'employee']);
$this->project = Project::create([
'code' => 'PRJ-A',
'name' => 'Alpha Project',
'status' => 'active',
]);
$this->project->members()->attach($this->employee->id, ['assigned_role' => 'Developer']);
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
private function draftTimesheet(): Timesheet
{
return Timesheet::create([
'user_id' => $this->employee->id,
'project_id' => $this->project->id,
'project_role' => 'Developer',
'week_start' => $this->monday->toDateString(),
'hours' => [8, 0, 0, 0, 0, 0, 0],
'overtime_hours' => [0, 0, 0, 0, 0, 0, 0],
'tasks' => ['Dev', '', '', '', '', '', ''],
'status' => 'draft',
]);
}
public function test_employee_can_upload_attachment_to_a_saved_row(): void
{
$timesheet = $this->draftTimesheet();
Livewire::actingAs($this->employee)
->test(WeeklyHours::class)
->set('weekStart', $this->monday->toDateString())
->set('rowUploads.0', UploadedFile::fake()->create('receipt.pdf', 120, 'application/pdf'))
->call('uploadAttachment', 0)
->assertHasNoErrors();
$attachment = $timesheet->attachments()->first();
$this->assertNotNull($attachment);
$this->assertSame('receipt.pdf', $attachment->original_name);
$this->assertSame($this->employee->id, $attachment->uploaded_by);
Storage::disk('local')->assertExists($attachment->path);
}
public function test_upload_rejects_disallowed_file_type(): void
{
$this->draftTimesheet();
Livewire::actingAs($this->employee)
->test(WeeklyHours::class)
->set('weekStart', $this->monday->toDateString())
->set('rowUploads.0', UploadedFile::fake()->create('malware.exe', 10, 'application/octet-stream'))
->call('uploadAttachment', 0)
->assertHasErrors('rowUploads.0');
$this->assertSame(0, TimesheetAttachment::count());
}
public function test_attachment_cannot_be_added_to_locked_timesheet(): void
{
$timesheet = $this->draftTimesheet();
$timesheet->update(['status' => 'approved']);
Livewire::actingAs($this->employee)
->test(WeeklyHours::class)
->set('weekStart', $this->monday->toDateString())
->set('rowUploads.0', UploadedFile::fake()->create('receipt.pdf', 50, 'application/pdf'))
->call('uploadAttachment', 0);
$this->assertSame(0, TimesheetAttachment::count());
}
public function test_employee_can_remove_own_attachment(): void
{
$timesheet = $this->draftTimesheet();
$path = UploadedFile::fake()->create('note.txt', 5)->store('timesheet-attachments/'.$timesheet->id, 'local');
$attachment = $timesheet->attachments()->create([
'uploaded_by' => $this->employee->id,
'disk' => 'local',
'path' => $path,
'original_name' => 'note.txt',
'mime_type' => 'text/plain',
'size' => 5,
]);
Livewire::actingAs($this->employee)
->test(WeeklyHours::class)
->set('weekStart', $this->monday->toDateString())
->call('removeAttachment', $attachment->id);
$this->assertSame(0, TimesheetAttachment::count());
Storage::disk('local')->assertMissing($path);
}
public function test_deleting_timesheet_removes_attachment_file(): void
{
$timesheet = $this->draftTimesheet();
$path = UploadedFile::fake()->create('note.txt', 5)->store('timesheet-attachments/'.$timesheet->id, 'local');
$timesheet->attachments()->create([
'uploaded_by' => $this->employee->id,
'disk' => 'local',
'path' => $path,
'original_name' => 'note.txt',
'size' => 5,
]);
$timesheet->delete();
$this->assertSame(0, TimesheetAttachment::count());
Storage::disk('local')->assertMissing($path);
}
public function test_owner_can_download_attachment_but_stranger_cannot(): void
{
$timesheet = $this->draftTimesheet();
$path = UploadedFile::fake()->create('note.txt', 5)->store('timesheet-attachments/'.$timesheet->id, 'local');
$attachment = $timesheet->attachments()->create([
'uploaded_by' => $this->employee->id,
'disk' => 'local',
'path' => $path,
'original_name' => 'note.txt',
'size' => 5,
]);
$stranger = User::factory()->create(['role' => 'employee']);
$this->actingAs($stranger)
->get(route('timesheet-attachments.download', $attachment))
->assertForbidden();
$this->actingAs($this->employee)
->get(route('timesheet-attachments.download', $attachment))
->assertOk();
}
}