Implementacao upload anexos de ordens
This commit is contained in:
@@ -12,3 +12,4 @@ database/
|
||||
.vscode/
|
||||
vendor/
|
||||
node_modules/
|
||||
storage/
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ agent/dist/*
|
||||
.idea
|
||||
.DS_Store
|
||||
mysql_data/
|
||||
storage/
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderAttachment;
|
||||
use App\Services\AttachmentService;
|
||||
use App\Services\OrderProcessor;
|
||||
use App\Services\TelegramService;
|
||||
use App\Utils\View;
|
||||
@@ -88,26 +90,32 @@ class OrderController
|
||||
$sql = "INSERT INTO orders (title, type, content, received_at) VALUES (:title, :type, :content, :received_at)";
|
||||
$stmt = $conn->prepare($sql);
|
||||
$stmt->execute([
|
||||
'title' => $title,
|
||||
'type' => $type,
|
||||
'content' => $content,
|
||||
'title' => $title,
|
||||
'type' => $type,
|
||||
'content' => $content,
|
||||
'received_at' => $received_at
|
||||
]);
|
||||
|
||||
$orderId = $conn->lastInsertId();
|
||||
|
||||
// Process CSV
|
||||
// Process CSV (unchanged)
|
||||
$processor = new OrderProcessor();
|
||||
$count = $processor->process($orderId, $type, $_FILES['csv_file']['tmp_name']);
|
||||
|
||||
// Store attachments (if any were sent)
|
||||
if (isset($_FILES['attachments']) && !empty($_FILES['attachments']['name'][0])) {
|
||||
$attachmentService = new AttachmentService();
|
||||
$attachmentService->storeFiles((int) $orderId, $_FILES['attachments']);
|
||||
}
|
||||
|
||||
// Send Telegram Notification
|
||||
$telegramService = new TelegramService();
|
||||
$typeLabel = ($type === 'block') ? 'Bloqueio' : (($type === 'unblock') ? 'Desbloqueio' : $type);
|
||||
|
||||
$telegramService->sendOrderNotification([
|
||||
'id' => $orderId,
|
||||
'id' => $orderId,
|
||||
'title' => $title,
|
||||
'type' => $typeLabel
|
||||
'type' => $typeLabel
|
||||
], $count);
|
||||
|
||||
$_SESSION['flash_success'] = "Ordem criada com sucesso! $count domínios processados.";
|
||||
@@ -134,11 +142,47 @@ class OrderController
|
||||
$stmt->execute(['id' => $id]);
|
||||
$items = $stmt->fetchAll();
|
||||
|
||||
// Get Attachments
|
||||
$attachmentModel = new OrderAttachment();
|
||||
$attachments = $attachmentModel->findByOrderId((int) $id);
|
||||
|
||||
View::render('layouts.admin', [
|
||||
'title' => 'Detalhes da Ordem #' . $id,
|
||||
'content' => __DIR__ . '/../../resources/views/admin/orders/view.php',
|
||||
'order' => $order,
|
||||
'items' => $items
|
||||
'title' => 'Detalhes da Ordem #' . $id,
|
||||
'content' => __DIR__ . '/../../resources/views/admin/orders/view.php',
|
||||
'order' => $order,
|
||||
'items' => $items,
|
||||
'attachments' => $attachments,
|
||||
]);
|
||||
}
|
||||
|
||||
public function downloadAttachment($id)
|
||||
{
|
||||
$attachmentModel = new OrderAttachment();
|
||||
$attachment = $attachmentModel->find($id);
|
||||
|
||||
if (!$attachment) {
|
||||
$_SESSION['flash_error'] = "Anexo não encontrado.";
|
||||
View::redirect('/admin/orders');
|
||||
return;
|
||||
}
|
||||
|
||||
$attachmentService = new AttachmentService();
|
||||
$filePath = $attachmentService->getFilePath((int) $attachment['order_id'], $attachment['stored_name']);
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
$_SESSION['flash_error'] = "Arquivo físico não encontrado no servidor.";
|
||||
View::redirect('/admin/orders/view/' . $attachment['order_id']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Serve o arquivo para download
|
||||
header('Content-Type: ' . ($attachment['mime_type'] ?? 'application/octet-stream'));
|
||||
header('Content-Disposition: attachment; filename="' . addslashes($attachment['original_name']) . '"');
|
||||
header('Content-Length: ' . filesize($filePath));
|
||||
header('Cache-Control: private, no-cache');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
readfile($filePath);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
41
app/Models/OrderAttachment.php
Normal file
41
app/Models/OrderAttachment.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\Model;
|
||||
|
||||
class OrderAttachment extends Model
|
||||
{
|
||||
protected $table = 'order_attachments';
|
||||
|
||||
/**
|
||||
* Retorna todos os anexos de uma ordem específica.
|
||||
*/
|
||||
public function findByOrderId(int $orderId): array
|
||||
{
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT * FROM {$this->table} WHERE order_id = :order_id ORDER BY created_at ASC"
|
||||
);
|
||||
$stmt->execute(['order_id' => $orderId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insere um registro de anexo no banco.
|
||||
*/
|
||||
public function create(array $data): int
|
||||
{
|
||||
$stmt = $this->conn->prepare(
|
||||
"INSERT INTO {$this->table} (order_id, original_name, stored_name, mime_type, size)
|
||||
VALUES (:order_id, :original_name, :stored_name, :mime_type, :size)"
|
||||
);
|
||||
$stmt->execute([
|
||||
'order_id' => $data['order_id'],
|
||||
'original_name' => $data['original_name'],
|
||||
'stored_name' => $data['stored_name'],
|
||||
'mime_type' => $data['mime_type'] ?? null,
|
||||
'size' => $data['size'] ?? null,
|
||||
]);
|
||||
return (int) $this->conn->lastInsertId();
|
||||
}
|
||||
}
|
||||
151
app/Services/AttachmentService.php
Normal file
151
app/Services/AttachmentService.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\OrderAttachment;
|
||||
|
||||
class AttachmentService
|
||||
{
|
||||
/**
|
||||
* Tipos de arquivo permitidos para anexo.
|
||||
* Mapeamento mime_type => extensões aceitas.
|
||||
*/
|
||||
private const ALLOWED_TYPES = [
|
||||
'application/pdf' => 'pdf',
|
||||
'image/png' => 'png',
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/gif' => 'gif',
|
||||
'application/msword' => 'doc',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
||||
'application/vnd.ms-excel' => 'xls',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
||||
'text/plain' => 'txt',
|
||||
];
|
||||
|
||||
/** Tamanho máximo por arquivo: 20 MB */
|
||||
private const MAX_SIZE = 20 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Raiz do diretório de uploads (fora do public/).
|
||||
*/
|
||||
private function getBaseUploadDir(): string
|
||||
{
|
||||
return dirname(__DIR__, 2) . '/storage/uploads/orders';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pega o caminho do diretório de upload de uma ordem específica.
|
||||
*/
|
||||
private function getOrderUploadDir(int $orderId): string
|
||||
{
|
||||
return $this->getBaseUploadDir() . '/' . $orderId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Garante que o diretório de upload da ordem exista.
|
||||
*/
|
||||
private function ensureDirectory(string $path): void
|
||||
{
|
||||
if (!is_dir($path)) {
|
||||
mkdir($path, 0775, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processa e armazena os arquivos do campo de upload.
|
||||
* Retorna a quantidade de arquivos salvos com sucesso.
|
||||
*
|
||||
* @param int $orderId
|
||||
* @param array $filesInput Entry do $_FILES (já normalizado como array de arquivos)
|
||||
* @return int
|
||||
* @throws \Exception em caso de erro crítico
|
||||
*/
|
||||
public function storeFiles(int $orderId, array $filesInput): int
|
||||
{
|
||||
$uploadDir = $this->getOrderUploadDir($orderId);
|
||||
$this->ensureDirectory($uploadDir);
|
||||
|
||||
$model = new OrderAttachment();
|
||||
$saved = 0;
|
||||
|
||||
// Normaliza o $_FILES para iterar arquivo a arquivo
|
||||
$files = $this->normalizeFilesArray($filesInput);
|
||||
|
||||
foreach ($files as $file) {
|
||||
// Ignora entradas vazias (campo não preenchido)
|
||||
if ($file['error'] === UPLOAD_ERR_NO_FILE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
throw new \Exception("Erro no upload do arquivo '{$file['name']}': código {$file['error']}");
|
||||
}
|
||||
|
||||
if ($file['size'] > self::MAX_SIZE) {
|
||||
throw new \Exception("O arquivo '{$file['name']}' excede o tamanho máximo permitido de 20 MB.");
|
||||
}
|
||||
|
||||
// Detecta o tipo MIME real do arquivo (não confiar só no header HTTP)
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->file($file['tmp_name']);
|
||||
|
||||
if (!array_key_exists($mimeType, self::ALLOWED_TYPES)) {
|
||||
throw new \Exception("Tipo de arquivo não permitido: '{$file['name']}' ({$mimeType}).");
|
||||
}
|
||||
|
||||
$extension = self::ALLOWED_TYPES[$mimeType];
|
||||
$storedName = uniqid('attach_', true) . '.' . $extension;
|
||||
$destination = $uploadDir . '/' . $storedName;
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $destination)) {
|
||||
throw new \Exception("Falha ao mover o arquivo '{$file['name']}' para o servidor.");
|
||||
}
|
||||
|
||||
$model->create([
|
||||
'order_id' => $orderId,
|
||||
'original_name' => $file['name'],
|
||||
'stored_name' => $storedName,
|
||||
'mime_type' => $mimeType,
|
||||
'size' => $file['size'],
|
||||
]);
|
||||
|
||||
$saved++;
|
||||
}
|
||||
|
||||
return $saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna o caminho absoluto do arquivo no disco.
|
||||
*/
|
||||
public function getFilePath(int $orderId, string $storedName): string
|
||||
{
|
||||
return $this->getOrderUploadDir($orderId) . '/' . $storedName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normaliza o array $_FILES para uma lista linear de arquivos,
|
||||
* independente de ser upload único ou múltiplo (name[]).
|
||||
*/
|
||||
private function normalizeFilesArray(array $files): array
|
||||
{
|
||||
// Se é um único arquivo, encapsula em array
|
||||
if (!is_array($files['name'])) {
|
||||
return [$files];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
$count = count($files['name']);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$normalized[] = [
|
||||
'name' => $files['name'][$i],
|
||||
'type' => $files['type'][$i],
|
||||
'tmp_name' => $files['tmp_name'][$i],
|
||||
'error' => $files['error'][$i],
|
||||
'size' => $files['size'][$i],
|
||||
];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ class OrderProcessor
|
||||
|
||||
$domains = [];
|
||||
while (($data = fgetcsv($handle, 1000, ",")) !== FALSE) {
|
||||
$raw = trim($data[0]);
|
||||
$raw = trim($data[0] ?? '');
|
||||
if (empty($raw))
|
||||
continue;
|
||||
|
||||
|
||||
@@ -84,6 +84,9 @@ $router->addMiddleware(\App\Middleware\AdminMiddleware::class);
|
||||
$router->get('/admin/orders/view/{id}', [\App\Controllers\OrderController::class, 'view']);
|
||||
$router->addMiddleware(\App\Middleware\AdminMiddleware::class);
|
||||
|
||||
$router->get('/admin/orders/attachments/{id}/download', [\App\Controllers\OrderController::class, 'downloadAttachment']);
|
||||
$router->addMiddleware(\App\Middleware\AdminMiddleware::class);
|
||||
|
||||
// Settings
|
||||
$router->get('/admin/settings', [\App\Controllers\SettingsController::class, 'index']);
|
||||
$router->addMiddleware(\App\Middleware\AdminMiddleware::class);
|
||||
|
||||
17
database/migrations/2026_03_12_create_order_attachments.sql
Normal file
17
database/migrations/2026_03_12_create_order_attachments.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Migration: Criação da tabela order_attachments
|
||||
-- Data: 2026-03-12
|
||||
-- Descrição: Armazena metadados de arquivos anexados às ordens judiciais.
|
||||
-- Os arquivos físicos ficam em storage/uploads/orders/{order_id}/
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `order_attachments` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`order_id` int(11) NOT NULL,
|
||||
`original_name` varchar(255) NOT NULL COMMENT 'Nome original do arquivo enviado pelo usuário',
|
||||
`stored_name` varchar(255) NOT NULL COMMENT 'Nome gerado para armazenamento no disco',
|
||||
`mime_type` varchar(100) DEFAULT NULL,
|
||||
`size` int(11) DEFAULT NULL COMMENT 'Tamanho em bytes',
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_attachments_order` (`order_id`),
|
||||
CONSTRAINT `fk_attachments_order` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -143,4 +143,21 @@ CREATE TABLE `settings` (
|
||||
PRIMARY KEY (`key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
--
|
||||
-- Tabela `order_attachments`
|
||||
--
|
||||
CREATE TABLE IF NOT EXISTS `order_attachments` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`order_id` int(11) NOT NULL,
|
||||
`original_name` varchar(255) NOT NULL COMMENT 'Nome original do arquivo enviado pelo usuário',
|
||||
`stored_name` varchar(255) NOT NULL COMMENT 'Nome gerado para armazenamento no disco',
|
||||
`mime_type` varchar(100) DEFAULT NULL,
|
||||
`size` int(11) DEFAULT NULL COMMENT 'Tamanho em bytes',
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_attachments_order` (`order_id`),
|
||||
CONSTRAINT `fk_attachments_order` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
@@ -68,6 +68,38 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Anexos <span class="text-gray-400 font-normal">(PDF, imagens, documentos — opcional)</span>
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mb-2">Arquivos da ordem judicial (decisão, ofícios, etc.). Tipos aceitos: PDF, PNG, JPG, GIF, DOC, DOCX, XLS, XLSX, TXT — máx. 20 MB por arquivo.</p>
|
||||
<div x-data="{ attachNames: [] }" class="mt-1">
|
||||
<label class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer hover:border-primary-500 transition-colors bg-white">
|
||||
<div class="space-y-1 text-center w-full">
|
||||
<svg class="mx-auto h-10 w-10 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13" />
|
||||
</svg>
|
||||
<div class="flex text-sm text-gray-600 justify-center">
|
||||
<span class="font-medium text-primary-600 hover:text-primary-500">Selecionar arquivos</span>
|
||||
<p class="pl-1">ou arraste e solte</p>
|
||||
</div>
|
||||
<template x-if="attachNames.length > 0">
|
||||
<ul class="text-xs text-gray-700 mt-2 text-left list-disc list-inside">
|
||||
<template x-for="name in attachNames" :key="name">
|
||||
<li x-text="name"></li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
<p x-show="attachNames.length === 0" class="text-xs text-gray-400 mt-1">Nenhum arquivo selecionado</p>
|
||||
</div>
|
||||
<input type="file" name="attachments[]" class="sr-only"
|
||||
accept=".pdf,.png,.jpg,.jpeg,.gif,.doc,.docx,.xls,.xlsx,.txt"
|
||||
multiple
|
||||
@change="attachNames = Array.from($event.target.files).map(f => f.name)">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4 border-t border-gray-100">
|
||||
|
||||
@@ -29,6 +29,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($attachments)): ?>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-100 bg-gray-50 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 002.112 2.13" />
|
||||
</svg>
|
||||
<h3 class="text-sm font-semibold text-gray-800">Anexos (<?= count($attachments) ?>)</h3>
|
||||
</div>
|
||||
<ul class="divide-y divide-gray-100">
|
||||
<?php foreach ($attachments as $attachment): ?>
|
||||
<li class="flex items-center justify-between px-6 py-3 hover:bg-gray-50 transition-colors">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
<span class="text-sm text-gray-800 truncate"><?= htmlspecialchars($attachment['original_name']) ?></span>
|
||||
<?php if ($attachment['size']): ?>
|
||||
<span class="text-xs text-gray-400 flex-shrink-0"><?= number_format($attachment['size'] / 1024, 1) ?> KB</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<a href="/admin/orders/attachments/<?= (int) $attachment['id'] ?>/download"
|
||||
class="flex items-center gap-1 text-xs text-primary-600 hover:text-primary-800 font-medium flex-shrink-0 ml-4"
|
||||
title="Baixar <?= htmlspecialchars($attachment['original_name']) ?>">
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
Baixar
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-100 bg-gray-50">
|
||||
<h3 class="text-lg font-semibold text-gray-800">Domínios Afetados (<?= count($items) ?>)</h3>
|
||||
|
||||
Reference in New Issue
Block a user