'boolean', 'file_size' => 'integer', 'deleted_at' => 'datetime', ]; /** * Get the tenant that owns the file */ public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); } /** * Get the folder that contains this file */ public function folder(): BelongsTo { return $this->belongsTo(Folder::class); } /** * Get all share links for this file */ public function shareLinks(): HasMany { return $this->hasMany(FileShareLink::class); } /** * Get the uploader (User) */ public function uploader(): BelongsTo { return $this->belongsTo(User::class, 'uploaded_by'); } /** * Legacy: 연관된 모델 (Polymorphic) - for backward compatibility * * @deprecated Use document_id and document_type instead */ public function fileable() { return $this->morphTo(); } /** * Get the full storage path */ public function getStoragePath(): string { return $this->file_path; } /** * Check if file exists in storage */ public function exists(): bool { return Storage::disk('r2')->exists($this->file_path); } /** * Get download response (streaming from R2) * * @param bool $inline true = 브라우저에서 바로 표시 (이미지/PDF), false = 다운로드 */ public function download(bool $inline = false) { if (! $this->exists()) { abort(404, 'File not found in storage'); } $fileName = $this->display_name ?? $this->original_name; $mimeType = $this->mime_type ?? 'application/octet-stream'; $disposition = $inline ? 'inline' : 'attachment'; // Stream from R2 (메모리에 전체 로드하지 않음) $stream = Storage::disk('r2')->readStream($this->file_path); return response()->stream(function () use ($stream) { fpassthru($stream); if (is_resource($stream)) { fclose($stream); } }, 200, [ 'Content-Type' => $mimeType, 'Content-Disposition' => $disposition . '; filename="' . $fileName . '"', 'Content-Length' => $this->file_size, 'Cache-Control' => 'private, max-age=3600', ]); } /** * Move file from temp to folder */ public function moveToFolder(Folder $folder): bool { if (! $this->is_temp) { return false; // Already moved } // New path: /tenants/{tenant_id}/{folder_key}/{year}/{month}/{stored_name} $date = now(); $newPath = sprintf( '%d/%s/%s/%s/%s', $this->tenant_id, $folder->folder_key, $date->format('Y'), $date->format('m'), $this->stored_name ?? $this->file_name ); // Move physical file in R2 if (Storage::disk('r2')->exists($this->file_path)) { Storage::disk('r2')->move($this->file_path, $newPath); } // Update DB $this->update([ 'file_path' => $newPath, 'folder_id' => $folder->id, 'is_temp' => false, ]); return true; } /** * Delete file (soft delete) */ public function softDeleteFile(int $userId): void { // Set deleted_by before soft delete $this->deleted_by = $userId; $this->save(); // Use SoftDeletes trait's delete() method $this->delete(); } /** * Permanently delete file */ public function permanentDelete(): void { // Delete physical file from R2 if ($this->exists()) { Storage::disk('r2')->delete($this->file_path); } // Decrement tenant storage $tenant = Tenant::find($this->tenant_id); if ($tenant) { $tenant->decrement('storage_used', $this->file_size); } // Force delete from DB $this->forceDelete(); } /** * Scope: Temp files only */ public function scopeTemp($query) { return $query->where('is_temp', true); } /** * Scope: Non-temp files only */ public function scopeNonTemp($query) { return $query->where('is_temp', false); } /** * Scope: By folder */ public function scopeInFolder($query, $folderId) { return $query->where('folder_id', $folderId); } /** * Scope: By document */ public function scopeForDocument($query, int $documentId, string $documentType) { return $query->where('document_id', $documentId) ->where('document_type', $documentType); } }