Compare commits
24 Commits
feature/co
...
b0508d79b3
| Author | SHA1 | Date | |
|---|---|---|---|
| b0508d79b3 | |||
| 67bc9e6cb4 | |||
| 4125d8353d | |||
| 31b65eb676 | |||
| 8708cea122 | |||
| 54aa696145 | |||
| 1f9d876bc3 | |||
| 0dae985230 | |||
| 4c237fb729 | |||
| edb4c128d6 | |||
| 2ce21799c7 | |||
| 17799122e2 | |||
| e4448d810e | |||
| 7994fe0c96 | |||
| ee59236d55 | |||
| aec92d5346 | |||
| 6db98ddc1a | |||
| 56b87f0c12 | |||
| 9c5a29d933 | |||
| 049484dee5 | |||
| 3360e81e96 | |||
| 93aead8dbf | |||
| 740566b02d | |||
| b874a45724 |
@@ -56,10 +56,13 @@ MAIL_PASSWORD=null
|
|||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
|
||||||
|
AWS_ENDPOINT=
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
AWS_DEFAULT_REGION=
|
||||||
AWS_BUCKET=
|
AWS_BUCKET=
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=
|
||||||
|
AWS_HTTP_VERIFY=
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|||||||
357
Tenant_CRUD_Postman_Collection.json
Normal file
357
Tenant_CRUD_Postman_Collection.json
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"_postman_id": "76df1733-a3d8-4f2b-9ffb-7c70e3032549",
|
||||||
|
"name": "ShopIt - Tenant CRUD",
|
||||||
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
|
},
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "List Tenants",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/tenants",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"tenants"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bootstrap Tenant",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/tenants/bootstrap/acme.com",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"tenants",
|
||||||
|
"bootstrap",
|
||||||
|
"acme.com"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Create Tenant (JSON)",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"codigo\": \"acme\",\n \"nombre\": \"Acme Corporation\",\n \"dominio\": \"acme.com\",\n \"primary_color\": \"#111111\",\n \"secondary_color\": \"#222222\",\n \"danger_color\": \"#333333\",\n \"header_footer_bg_color\": \"#444444\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/tenants",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"tenants"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Create Tenant (Form Data)",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "formdata",
|
||||||
|
"formdata": [
|
||||||
|
{
|
||||||
|
"key": "codigo",
|
||||||
|
"value": "acme",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "nombre",
|
||||||
|
"value": "Acme Corporation",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "dominio",
|
||||||
|
"value": "acme.com",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "primary_color",
|
||||||
|
"value": "#111111",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "secondary_color",
|
||||||
|
"value": "#222222",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "danger_color",
|
||||||
|
"value": "#333333",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "header_footer_bg_color",
|
||||||
|
"value": "#444444",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "header_logo",
|
||||||
|
"type": "file",
|
||||||
|
"src": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "footer_logo",
|
||||||
|
"type": "file",
|
||||||
|
"src": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/tenants",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"tenants"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Show Tenant",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/tenants/acme",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"tenants",
|
||||||
|
"acme"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Update Tenant (JSON)",
|
||||||
|
"request": {
|
||||||
|
"method": "PUT",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"codigo\": \"acme\",\n \"nombre\": \"Acme Corporation Updated\",\n \"dominio\": \"acme.com\",\n \"primary_color\": \"#555555\",\n \"secondary_color\": \"#666666\",\n \"danger_color\": \"#777777\",\n \"header_footer_bg_color\": \"#888888\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/tenants/acme",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"tenants",
|
||||||
|
"acme"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Update Tenant (Form Data)",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "formdata",
|
||||||
|
"formdata": [
|
||||||
|
{
|
||||||
|
"key": "_method",
|
||||||
|
"value": "PUT",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "codigo",
|
||||||
|
"value": "acme",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "nombre",
|
||||||
|
"value": "Acme Corporation Updated",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "dominio",
|
||||||
|
"value": "acme.com",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "primary_color",
|
||||||
|
"value": "#555555",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "secondary_color",
|
||||||
|
"value": "#666666",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "danger_color",
|
||||||
|
"value": "#777777",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "header_footer_bg_color",
|
||||||
|
"value": "#888888",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "header_logo",
|
||||||
|
"type": "file",
|
||||||
|
"src": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "footer_logo",
|
||||||
|
"type": "file",
|
||||||
|
"src": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/tenants/acme",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"tenants",
|
||||||
|
"acme"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Delete Tenant",
|
||||||
|
"request": {
|
||||||
|
"method": "DELETE",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Accept",
|
||||||
|
"value": "application/json",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/tenants/acme",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"tenants",
|
||||||
|
"acme"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "prerequest",
|
||||||
|
"script": {
|
||||||
|
"type": "text/javascript",
|
||||||
|
"exec": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"type": "text/javascript",
|
||||||
|
"exec": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"variable": [
|
||||||
|
{
|
||||||
|
"key": "base_url",
|
||||||
|
"value": "http://localhost:8000",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
13
app/Domains/Attachable/Enums/AttachmentType.php
Normal file
13
app/Domains/Attachable/Enums/AttachmentType.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Attachable\Enums;
|
||||||
|
|
||||||
|
enum AttachmentType: string
|
||||||
|
{
|
||||||
|
case Image = 'image';
|
||||||
|
case Video = 'video';
|
||||||
|
case Pdf = 'pdf';
|
||||||
|
case Document = 'document';
|
||||||
|
case Audio = 'audio';
|
||||||
|
case Other = 'other';
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Attachable\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class AttachmentStorageException extends RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
53
app/Domains/Attachable/Models/Attachment.php
Normal file
53
app/Domains/Attachable/Models/Attachment.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Attachable\Models;
|
||||||
|
|
||||||
|
use App\Domains\Attachable\Enums\AttachmentType;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'key',
|
||||||
|
'path',
|
||||||
|
'filename',
|
||||||
|
'type',
|
||||||
|
'mime_type',
|
||||||
|
'extension',
|
||||||
|
'size',
|
||||||
|
])]
|
||||||
|
class Attachment extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'attachments';
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (self $attachment): void {
|
||||||
|
if (! $attachment->key) {
|
||||||
|
$attachment->key = (string) Str::uuid();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => AttachmentType::class,
|
||||||
|
'size' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the pre-signed temporary S3 URL for this attachment.
|
||||||
|
*/
|
||||||
|
public function getTemporaryUrl(int $expiresInMinutes = 10): string
|
||||||
|
{
|
||||||
|
return \Illuminate\Support\Facades\Storage::disk('s3')->temporaryUrl(
|
||||||
|
$this->path,
|
||||||
|
now()->addMinutes($expiresInMinutes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
253
app/Domains/Attachable/Services/AttachmentService.php
Normal file
253
app/Domains/Attachable/Services/AttachmentService.php
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Attachable\Services;
|
||||||
|
|
||||||
|
use App\Domains\Attachable\Enums\AttachmentType;
|
||||||
|
use App\Domains\Attachable\Exceptions\AttachmentStorageException;
|
||||||
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\Mime\MimeTypes;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class AttachmentService
|
||||||
|
{
|
||||||
|
public function store(
|
||||||
|
UploadedFile|string $file,
|
||||||
|
string $path,
|
||||||
|
): Attachment {
|
||||||
|
$normalizedPath = $this->normalizeDirectory($path);
|
||||||
|
|
||||||
|
if ($normalizedPath === '') {
|
||||||
|
throw new AttachmentStorageException('The attachment path cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = (string) Str::uuid();
|
||||||
|
$fileData = $this->resolveFileData($file, $key);
|
||||||
|
$storedPath = $this->storeFile(
|
||||||
|
$fileData,
|
||||||
|
$normalizedPath,
|
||||||
|
$this->buildStoredFilename($key, $fileData['extension']),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! is_string($storedPath) || $storedPath === '') {
|
||||||
|
throw new AttachmentStorageException('No se pudo subir el archivo al disco s3.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
/** @var Attachment $attachment */
|
||||||
|
$attachment = Attachment::query()->create([
|
||||||
|
'key' => $key,
|
||||||
|
'path' => $storedPath,
|
||||||
|
'filename' => $this->resolveFilename($file, $key),
|
||||||
|
'type' => $this->resolveAttachmentType($fileData['mime_type']),
|
||||||
|
'mime_type' => $fileData['mime_type'],
|
||||||
|
'extension' => $fileData['extension'],
|
||||||
|
'size' => $fileData['size'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $attachment;
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
Storage::disk('s3')->delete($storedPath);
|
||||||
|
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(Attachment $attachment): void
|
||||||
|
{
|
||||||
|
$deleted = Storage::disk('s3')->delete($attachment->path);
|
||||||
|
|
||||||
|
if (! $deleted) {
|
||||||
|
throw new AttachmentStorageException('No se pudo eliminar el archivo del disco s3.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachment->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function normalizeDirectory(string $path): string
|
||||||
|
{
|
||||||
|
return trim($path, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveFilename(UploadedFile|string $file, string $key): string
|
||||||
|
{
|
||||||
|
if ($file instanceof UploadedFile) {
|
||||||
|
$originalName = trim($file->getClientOriginalName());
|
||||||
|
|
||||||
|
if ($originalName !== '') {
|
||||||
|
return $originalName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{contents: string|null, extension: string, file: UploadedFile|null, mime_type: string, size: int}
|
||||||
|
*/
|
||||||
|
protected function resolveFileData(UploadedFile|string $file, string $filename): array
|
||||||
|
{
|
||||||
|
if ($file instanceof UploadedFile) {
|
||||||
|
return $this->fileDataFromUploadedFile($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->fileDataFromBase64($file, $filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{contents: string|null, extension: string, file: UploadedFile, mime_type: string, size: int}
|
||||||
|
*/
|
||||||
|
protected function fileDataFromUploadedFile(UploadedFile $file): array
|
||||||
|
{
|
||||||
|
$mimeType = strtolower($file->getClientMimeType() ?? $file->getMimeType() ?? 'application/octet-stream');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'contents' => null,
|
||||||
|
'extension' => strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: ''),
|
||||||
|
'file' => $file,
|
||||||
|
'mime_type' => $mimeType,
|
||||||
|
'size' => $file->getSize() ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{contents: string, extension: string, file: null, mime_type: string, size: int}
|
||||||
|
*/
|
||||||
|
protected function fileDataFromBase64(string $file, string $filename): array
|
||||||
|
{
|
||||||
|
['data' => $contents, 'mime_type' => $declaredMimeType] = $this->decodeBase64File($file);
|
||||||
|
$mimeType = $this->detectMimeType($contents, $declaredMimeType);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'contents' => $contents,
|
||||||
|
'extension' => $this->resolveExtension($filename, $mimeType),
|
||||||
|
'file' => null,
|
||||||
|
'mime_type' => $mimeType,
|
||||||
|
'size' => strlen($contents),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{data: string, mime_type: string|null}
|
||||||
|
*/
|
||||||
|
protected function decodeBase64File(string $file): array
|
||||||
|
{
|
||||||
|
$payload = trim($file);
|
||||||
|
|
||||||
|
if ($payload === '') {
|
||||||
|
throw new AttachmentStorageException('The base64 attachment content cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$declaredMimeType = null;
|
||||||
|
|
||||||
|
if (preg_match('/^data:(?<mime>[-\w.+\/]+);base64,(?<data>.+)$/s', $payload, $matches) === 1) {
|
||||||
|
$declaredMimeType = strtolower($matches['mime']);
|
||||||
|
$payload = $matches['data'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = base64_decode(preg_replace('/\s+/', '', $payload), true);
|
||||||
|
|
||||||
|
if ($decoded === false || $decoded === '') {
|
||||||
|
throw new AttachmentStorageException('The attachment base64 payload is invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'data' => $decoded,
|
||||||
|
'mime_type' => $declaredMimeType,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function detectMimeType(string $contents, ?string $fallback = null): string
|
||||||
|
{
|
||||||
|
if (is_string($fallback) && $fallback !== '') {
|
||||||
|
return strtolower($fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
$detectedMimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($contents);
|
||||||
|
|
||||||
|
if (is_string($detectedMimeType) && $detectedMimeType !== '') {
|
||||||
|
return strtolower($detectedMimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtolower($fallback ?? 'application/octet-stream');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveExtension(string $filename, string $mimeType): string
|
||||||
|
{
|
||||||
|
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||||
|
|
||||||
|
if ($extension !== '') {
|
||||||
|
return $extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtolower(MimeTypes::getDefault()->getExtensions($mimeType)[0] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{contents: string|null, extension: string, file: UploadedFile|null, mime_type: string, size: int} $fileData
|
||||||
|
*/
|
||||||
|
protected function storeFile(array $fileData, string $directory, string $storedFilename): string
|
||||||
|
{
|
||||||
|
if ($fileData['file'] instanceof UploadedFile) {
|
||||||
|
return Storage::disk('s3')->putFileAs($directory, $fileData['file'], $storedFilename);
|
||||||
|
}
|
||||||
|
|
||||||
|
$storedPath = $directory !== ''
|
||||||
|
? $directory.'/'.$storedFilename
|
||||||
|
: $storedFilename;
|
||||||
|
|
||||||
|
$stored = Storage::disk('s3')->put($storedPath, $fileData['contents'] ?? '');
|
||||||
|
|
||||||
|
if (! $stored) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $storedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveAttachmentType(string $mimeType): AttachmentType
|
||||||
|
{
|
||||||
|
$mimeType = strtolower($mimeType);
|
||||||
|
|
||||||
|
if (str_starts_with($mimeType, 'image/')) {
|
||||||
|
return AttachmentType::Image;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($mimeType, 'video/')) {
|
||||||
|
return AttachmentType::Video;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mimeType === 'application/pdf') {
|
||||||
|
return AttachmentType::Pdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($mimeType, 'audio/')) {
|
||||||
|
return AttachmentType::Audio;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
str_starts_with($mimeType, 'text/')
|
||||||
|
|| str_contains($mimeType, 'document')
|
||||||
|
|| str_contains($mimeType, 'word')
|
||||||
|
|| str_contains($mimeType, 'excel')
|
||||||
|
|| str_contains($mimeType, 'spreadsheet')
|
||||||
|
|| str_contains($mimeType, 'presentation')
|
||||||
|
|| str_contains($mimeType, 'officedocument')
|
||||||
|
) {
|
||||||
|
return AttachmentType::Document;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AttachmentType::Other;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildStoredFilename(string $key, string $extension): string
|
||||||
|
{
|
||||||
|
if ($extension === '') {
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $key.'.'.$extension;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ class BrandController extends Controller
|
|||||||
public function index(Tenant $tenant): JsonResponse
|
public function index(Tenant $tenant): JsonResponse
|
||||||
{
|
{
|
||||||
return BrandResource::collection(
|
return BrandResource::collection(
|
||||||
Brand::query()->where('tenant_codigo', $tenant->codigo)->orderByDesc('id')->get()
|
Brand::query()->where('tenant_codigo', $tenant->codigo)->orderByDesc('id')->paginateFromRequest()
|
||||||
)->response();
|
)->response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class CategoryController extends Controller
|
|||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
->with(['parent', 'subCategories', 'tenant'])
|
->with(['parent', 'subCategories', 'tenant'])
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
->get()
|
->paginateFromRequest()
|
||||||
)->response();
|
)->response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use App\Domains\Catalog\Models\ProductAttribute;
|
|||||||
use App\Domains\Catalog\Requests\StoreProductAttributeRequest;
|
use App\Domains\Catalog\Requests\StoreProductAttributeRequest;
|
||||||
use App\Domains\Catalog\Requests\UpdateProductAttributeRequest;
|
use App\Domains\Catalog\Requests\UpdateProductAttributeRequest;
|
||||||
use App\Domains\Catalog\Resources\ProductAttributeResource;
|
use App\Domains\Catalog\Resources\ProductAttributeResource;
|
||||||
use App\Domains\Catalog\Support\ProductAttributeType;
|
use App\Domains\Shared\Enums\FieldType;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -23,7 +23,7 @@ class ProductAttributeController extends Controller
|
|||||||
->with('options')
|
->with('options')
|
||||||
->latest();
|
->latest();
|
||||||
|
|
||||||
return ProductAttributeResource::collection($query->get())->response();
|
return ProductAttributeResource::collection($query->paginateFromRequest())->response();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(StoreProductAttributeRequest $request, Tenant $tenant): JsonResponse
|
public function store(StoreProductAttributeRequest $request, Tenant $tenant): JsonResponse
|
||||||
@@ -33,7 +33,7 @@ class ProductAttributeController extends Controller
|
|||||||
$options = $validated['options'] ?? [];
|
$options = $validated['options'] ?? [];
|
||||||
unset($validated['options']);
|
unset($validated['options']);
|
||||||
|
|
||||||
$type = ProductAttributeType::from((string) $validated['type']);
|
$type = FieldType::from((string) $validated['type']);
|
||||||
if (! $type->supportsOptions()) {
|
if (! $type->supportsOptions()) {
|
||||||
$validated['metadata_schema'] = null;
|
$validated['metadata_schema'] = null;
|
||||||
$options = [];
|
$options = [];
|
||||||
@@ -68,7 +68,7 @@ class ProductAttributeController extends Controller
|
|||||||
$options = $validated['options'] ?? [];
|
$options = $validated['options'] ?? [];
|
||||||
unset($validated['options']);
|
unset($validated['options']);
|
||||||
|
|
||||||
$type = ProductAttributeType::from((string) $validated['type']);
|
$type = FieldType::from((string) $validated['type']);
|
||||||
if (! $type->supportsOptions()) {
|
if (! $type->supportsOptions()) {
|
||||||
$validated['metadata_schema'] = null;
|
$validated['metadata_schema'] = null;
|
||||||
$options = [];
|
$options = [];
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class ProductController extends Controller
|
|||||||
public function index(Tenant $tenant): JsonResponse
|
public function index(Tenant $tenant): JsonResponse
|
||||||
{
|
{
|
||||||
return ProductResource::collection(
|
return ProductResource::collection(
|
||||||
Product::query()->where('tenant_codigo', $tenant->codigo)->latest()->get()
|
Product::query()->where('tenant_codigo', $tenant->codigo)->latest()->paginateFromRequest()
|
||||||
)->response();
|
)->response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ProductVariantController extends Controller
|
|||||||
->with(['product', 'definitions.attribute'])
|
->with(['product', 'definitions.attribute'])
|
||||||
->latest();
|
->latest();
|
||||||
|
|
||||||
return ProductVariantResource::collection($query->get())->response();
|
return ProductVariantResource::collection($query->paginateFromRequest())->response();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(StoreProductVariantRequest $request, Tenant $tenant): JsonResponse
|
public function store(StoreProductVariantRequest $request, Tenant $tenant): JsonResponse
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
use App\Domains\Catalog\Support\ProductAttributeType;
|
use App\Domains\Shared\Enums\FieldType;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -32,7 +32,7 @@ class ProductAttribute extends Model
|
|||||||
return [
|
return [
|
||||||
'is_required' => 'boolean',
|
'is_required' => 'boolean',
|
||||||
'metadata_schema' => 'array',
|
'metadata_schema' => 'array',
|
||||||
'type' => ProductAttributeType::class,
|
'type' => FieldType::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Requests;
|
namespace App\Domains\Catalog\Requests;
|
||||||
|
|
||||||
use App\Domains\Catalog\Support\ProductAttributeType;
|
use App\Domains\Shared\Enums\FieldType;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\Validator;
|
use Illuminate\Validation\Validator;
|
||||||
@@ -31,15 +31,15 @@ class StoreProductAttributeRequest extends FormRequest
|
|||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
'is_required' => ['sometimes', 'boolean'],
|
'is_required' => ['sometimes', 'boolean'],
|
||||||
'metadata_schema' => ['nullable', 'array'],
|
'metadata_schema' => ['nullable', 'array'],
|
||||||
'type' => ['required', Rule::enum(ProductAttributeType::class)],
|
'type' => ['required', Rule::enum(FieldType::class)],
|
||||||
'options' => [
|
'options' => [
|
||||||
Rule::requiredIf(fn (): bool => in_array($this->input('type'), [
|
Rule::requiredIf(fn (): bool => in_array($this->input('type'), [
|
||||||
ProductAttributeType::Select->value,
|
FieldType::Select->value,
|
||||||
ProductAttributeType::Multiselect->value,
|
FieldType::Multiselect->value,
|
||||||
], true)),
|
], true)),
|
||||||
Rule::prohibitedIf(fn (): bool => ! in_array($this->input('type'), [
|
Rule::prohibitedIf(fn (): bool => ! in_array($this->input('type'), [
|
||||||
ProductAttributeType::Select->value,
|
FieldType::Select->value,
|
||||||
ProductAttributeType::Multiselect->value,
|
FieldType::Multiselect->value,
|
||||||
], true)),
|
], true)),
|
||||||
'array',
|
'array',
|
||||||
],
|
],
|
||||||
@@ -53,7 +53,7 @@ class StoreProductAttributeRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
$validator->after(function (Validator $validator): void {
|
$validator->after(function (Validator $validator): void {
|
||||||
$type = $this->input('type');
|
$type = $this->input('type');
|
||||||
$supportsOptions = in_array($type, [ProductAttributeType::Select->value, ProductAttributeType::Multiselect->value], true);
|
$supportsOptions = in_array($type, [FieldType::Select->value, FieldType::Multiselect->value], true);
|
||||||
|
|
||||||
if (! $supportsOptions && $this->filled('metadata_schema')) {
|
if (! $supportsOptions && $this->filled('metadata_schema')) {
|
||||||
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
|
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
namespace App\Domains\Catalog\Requests;
|
namespace App\Domains\Catalog\Requests;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\ProductAttribute;
|
use App\Domains\Catalog\Models\ProductAttribute;
|
||||||
use App\Domains\Catalog\Support\ProductAttributeType;
|
use App\Domains\Shared\Enums\FieldType;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\Validator;
|
use Illuminate\Validation\Validator;
|
||||||
@@ -35,7 +35,7 @@ class UpdateProductAttributeRequest extends FormRequest
|
|||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
'is_required' => ['sometimes', 'boolean'],
|
'is_required' => ['sometimes', 'boolean'],
|
||||||
'metadata_schema' => ['nullable', 'array'],
|
'metadata_schema' => ['nullable', 'array'],
|
||||||
'type' => ['required', Rule::enum(ProductAttributeType::class)],
|
'type' => ['required', Rule::enum(FieldType::class)],
|
||||||
'options' => ['sometimes', 'array'],
|
'options' => ['sometimes', 'array'],
|
||||||
'options.*.label' => ['required', 'string', 'max:255'],
|
'options.*.label' => ['required', 'string', 'max:255'],
|
||||||
'options.*.sort_order' => ['sometimes', 'integer'],
|
'options.*.sort_order' => ['sometimes', 'integer'],
|
||||||
@@ -47,7 +47,7 @@ class UpdateProductAttributeRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
$validator->after(function (Validator $validator): void {
|
$validator->after(function (Validator $validator): void {
|
||||||
$type = $this->input('type');
|
$type = $this->input('type');
|
||||||
$supportsOptions = in_array($type, [ProductAttributeType::Select->value, ProductAttributeType::Multiselect->value], true);
|
$supportsOptions = in_array($type, [FieldType::Select->value, FieldType::Multiselect->value], true);
|
||||||
|
|
||||||
if (! $supportsOptions && $this->filled('metadata_schema')) {
|
if (! $supportsOptions && $this->filled('metadata_schema')) {
|
||||||
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
|
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Support;
|
|
||||||
|
|
||||||
enum ProductAttributeType: string
|
|
||||||
{
|
|
||||||
case Text = 'text';
|
|
||||||
case Numeric = 'numeric';
|
|
||||||
case Select = 'select';
|
|
||||||
case Multiselect = 'multiselect';
|
|
||||||
|
|
||||||
public function supportsOptions(): bool
|
|
||||||
{
|
|
||||||
return in_array($this, [self::Select, self::Multiselect], true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,7 @@ class PurchaseController extends Controller
|
|||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
->where('user_id', $request->user()->id)
|
->where('user_id', $request->user()->id)
|
||||||
->latest()
|
->latest()
|
||||||
->get()
|
->paginateFromRequest()
|
||||||
)->response();
|
)->response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
27
app/Domains/Shared/Enums/FieldType.php
Normal file
27
app/Domains/Shared/Enums/FieldType.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Shared\Enums;
|
||||||
|
|
||||||
|
enum FieldType: string
|
||||||
|
{
|
||||||
|
case String = 'string';
|
||||||
|
case Number = 'number';
|
||||||
|
case Boolean = 'boolean';
|
||||||
|
case Select = 'select';
|
||||||
|
case Multiselect = 'multiselect';
|
||||||
|
case Color = 'color';
|
||||||
|
case Image = 'image';
|
||||||
|
|
||||||
|
public function supportsOptions(): bool
|
||||||
|
{
|
||||||
|
return in_array($this, [self::Select, self::Multiselect], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_column(self::cases(), 'value');
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Domains/StorageTest/Controllers/S3TestController.php
Normal file
58
app/Domains/StorageTest/Controllers/S3TestController.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\StorageTest\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Attachable\Services\AttachmentService;
|
||||||
|
use App\Domains\StorageTest\Requests\GenerateS3TemporaryUrlRequest;
|
||||||
|
use App\Domains\StorageTest\Requests\StoreS3TestFileRequest;
|
||||||
|
use App\Domains\StorageTest\Services\S3TestService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class S3TestController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected AttachmentService $attachmentService,
|
||||||
|
protected S3TestService $s3TestService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreS3TestFileRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$attachment = $this->attachmentService->store(
|
||||||
|
$request->file('file') ?? (string) $request->validated('file_base64'),
|
||||||
|
$request->validated('path'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$temporaryUrl = $this->s3TestService->generateTemporaryUrl(
|
||||||
|
$attachment->path,
|
||||||
|
(int) $request->validated('expires_in_minutes', 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(
|
||||||
|
[
|
||||||
|
'id' => $attachment->id,
|
||||||
|
'key' => $attachment->key,
|
||||||
|
'path' => $attachment->path,
|
||||||
|
'filename' => $attachment->filename,
|
||||||
|
'type' => $attachment->type->value,
|
||||||
|
'mime_type' => $attachment->mime_type,
|
||||||
|
'extension' => $attachment->extension,
|
||||||
|
'size' => $attachment->size,
|
||||||
|
'temporary_url' => $temporaryUrl['temporary_url'],
|
||||||
|
'temporary_url_expires_at' => $temporaryUrl['temporary_url_expires_at'],
|
||||||
|
],
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function temporaryUrl(GenerateS3TemporaryUrlRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(
|
||||||
|
$this->s3TestService->generateTemporaryUrl(
|
||||||
|
$request->validated('path'),
|
||||||
|
(int) $request->validated('expires_in_minutes', 10),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\StorageTest\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class GenerateS3TemporaryUrlRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'path' => ['required', 'string', 'max:2048'],
|
||||||
|
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:1440'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Domains/StorageTest/Requests/StoreS3TestFileRequest.php
Normal file
26
app/Domains/StorageTest/Requests/StoreS3TestFileRequest.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\StorageTest\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreS3TestFileRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'file' => ['nullable', 'file', 'max:10240', 'required_without:file_base64'],
|
||||||
|
'file_base64' => ['nullable', 'string', 'required_without:file'],
|
||||||
|
'path' => ['required', 'string', 'max:2048'],
|
||||||
|
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:1440'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/Domains/StorageTest/Services/S3TestService.php
Normal file
81
app/Domains/StorageTest/Services/S3TestService.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\StorageTest\Services;
|
||||||
|
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class S3TestService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, int|string|null>
|
||||||
|
*/
|
||||||
|
public function storeTestFile(
|
||||||
|
UploadedFile $file,
|
||||||
|
?string $directory = null,
|
||||||
|
int $expiresInMinutes = 10,
|
||||||
|
): array {
|
||||||
|
$directory = $this->normalizeDirectory($directory);
|
||||||
|
$disk = Storage::disk('s3');
|
||||||
|
$path = $disk->putFile($directory, $file);
|
||||||
|
|
||||||
|
if (! is_string($path) || $path === '') {
|
||||||
|
Log::error('S3 upload returned an empty path.', [
|
||||||
|
'disk' => 's3',
|
||||||
|
'directory' => $directory,
|
||||||
|
'original_name' => $file->getClientOriginalName(),
|
||||||
|
'mime_type' => $file->getClientMimeType(),
|
||||||
|
'size' => $file->getSize(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new RuntimeException('No se pudo subir el archivo al disco s3.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'disk' => 's3',
|
||||||
|
'directory' => $directory,
|
||||||
|
'key' => $path,
|
||||||
|
'path' => $path,
|
||||||
|
'filename' => basename($path),
|
||||||
|
'original_name' => $file->getClientOriginalName(),
|
||||||
|
'mime_type' => $file->getClientMimeType(),
|
||||||
|
'extension' => $file->extension(),
|
||||||
|
'size' => $file->getSize(),
|
||||||
|
'temporary_url' => $disk->temporaryUrl($path, now()->addMinutes($expiresInMinutes)),
|
||||||
|
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function generateTemporaryUrl(string $path, int $expiresInMinutes = 10): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'disk' => 's3',
|
||||||
|
'key' => $path,
|
||||||
|
'path' => $path,
|
||||||
|
'temporary_url' => $this->temporaryUrlForPath($path, $expiresInMinutes),
|
||||||
|
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function temporaryUrlForPath(string $path, int $expiresInMinutes): string
|
||||||
|
{
|
||||||
|
return Storage::disk('s3')->temporaryUrl($path, now()->addMinutes($expiresInMinutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function normalizeDirectory(?string $directory): string
|
||||||
|
{
|
||||||
|
$directory = trim((string) $directory, '/');
|
||||||
|
|
||||||
|
if ($directory !== '') {
|
||||||
|
return $directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'testing/attachments/'.now()->format('Y/m/d').'/'.Str::uuid();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
app/Domains/StorageTest/routes/api.php
Normal file
9
app/Domains/StorageTest/routes/api.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\StorageTest\Controllers\S3TestController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('storage-test/s3')->group(function (): void {
|
||||||
|
Route::post('upload', [S3TestController::class, 'store']);
|
||||||
|
Route::get('temporary-url', [S3TestController::class, 'temporaryUrl']);
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ class BootstrapTenantController extends Controller
|
|||||||
$dominio = $request->validated('dominio');
|
$dominio = $request->validated('dominio');
|
||||||
|
|
||||||
return TenantResource::make(
|
return TenantResource::make(
|
||||||
Tenant::query()->where('dominio', $dominio)->firstOrFail()
|
Tenant::query()->with(['headerLogo', 'footerLogo'])->where('dominio', $dominio)->firstOrFail()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,40 +6,39 @@ use App\Domains\Tenant\Models\Tenant;
|
|||||||
use App\Domains\Tenant\Requests\StoreTenantRequest;
|
use App\Domains\Tenant\Requests\StoreTenantRequest;
|
||||||
use App\Domains\Tenant\Requests\UpdateTenantRequest;
|
use App\Domains\Tenant\Requests\UpdateTenantRequest;
|
||||||
use App\Domains\Tenant\Resources\TenantResource;
|
use App\Domains\Tenant\Resources\TenantResource;
|
||||||
|
use App\Domains\Tenant\Services\TenantService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
class TenantController extends Controller
|
class TenantController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(protected TenantService $tenantService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
return TenantResource::collection(Tenant::query()->latest()->get())->response();
|
return TenantResource::collection(Tenant::query()->with(['headerLogo', 'footerLogo'])->latest()->paginateFromRequest())->response();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(StoreTenantRequest $request): JsonResponse
|
public function store(StoreTenantRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$tenant = $this->tenantService->create($request->validated());
|
||||||
$props = $validated['props'] ?? [];
|
|
||||||
|
|
||||||
$tenant = Tenant::createWithProps($validated, $props);
|
return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo']))->response()->setStatusCode(201);
|
||||||
|
|
||||||
return TenantResource::make($tenant)->response()->setStatusCode(201);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Tenant $tenant): TenantResource
|
public function show(Tenant $tenant): TenantResource
|
||||||
{
|
{
|
||||||
return TenantResource::make($tenant);
|
return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo']));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource
|
public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$tenant = $this->tenantService->update($tenant, $request->validated());
|
||||||
$props = $validated['props'] ?? [];
|
|
||||||
|
|
||||||
$tenant->updateWithProps($validated, $props);
|
return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo']));
|
||||||
|
|
||||||
return TenantResource::make($tenant);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Tenant $tenant): Response
|
public function destroy(Tenant $tenant): Response
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Controllers;
|
|
||||||
|
|
||||||
use App\Domains\Tenant\Models\TenantProp;
|
|
||||||
use App\Domains\Tenant\Requests\StoreTenantPropRequest;
|
|
||||||
use App\Domains\Tenant\Requests\UpdateTenantPropRequest;
|
|
||||||
use App\Domains\Tenant\Resources\TenantPropResource;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Response;
|
|
||||||
|
|
||||||
class TenantPropController extends Controller
|
|
||||||
{
|
|
||||||
public function index(): JsonResponse
|
|
||||||
{
|
|
||||||
return TenantPropResource::collection(TenantProp::query()->latest()->get())->response();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(StoreTenantPropRequest $request): JsonResponse
|
|
||||||
{
|
|
||||||
$tenantProp = TenantProp::query()->create($request->validated());
|
|
||||||
|
|
||||||
return TenantPropResource::make($tenantProp)->response()->setStatusCode(201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(TenantProp $tenantProp): TenantPropResource
|
|
||||||
{
|
|
||||||
return TenantPropResource::make($tenantProp);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(UpdateTenantPropRequest $request, TenantProp $tenantProp): TenantPropResource
|
|
||||||
{
|
|
||||||
$tenantProp->update($request->validated());
|
|
||||||
|
|
||||||
return TenantPropResource::make($tenantProp);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(TenantProp $tenantProp): Response
|
|
||||||
{
|
|
||||||
$tenantProp->delete();
|
|
||||||
|
|
||||||
return response()->noContent();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,42 +2,48 @@
|
|||||||
|
|
||||||
namespace App\Domains\Tenant\Models;
|
namespace App\Domains\Tenant\Models;
|
||||||
|
|
||||||
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use App\Domains\Catalog\Models\Product;
|
use App\Domains\Catalog\Models\Product;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use LogicException;
|
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'codigo',
|
'codigo',
|
||||||
'nombre',
|
'nombre',
|
||||||
'dominio',
|
'dominio',
|
||||||
|
'primary_color',
|
||||||
|
'secondary_color',
|
||||||
|
'danger_color',
|
||||||
|
'header_footer_bg_color',
|
||||||
|
'header_logo_id',
|
||||||
|
'footer_logo_id',
|
||||||
])]
|
])]
|
||||||
class Tenant extends Model
|
class Tenant extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
/**
|
public function getRouteKeyName(): string
|
||||||
* @param array<string, mixed> $attributes
|
|
||||||
* @param array<string, mixed> $props
|
|
||||||
* @return static
|
|
||||||
*/
|
|
||||||
public static function createWithProps(array $attributes, array $props = []): static
|
|
||||||
{
|
{
|
||||||
unset($attributes['props']);
|
return 'codigo';
|
||||||
|
}
|
||||||
|
|
||||||
/** @var static $tenant */
|
/**
|
||||||
$tenant = DB::transaction(function () use ($attributes, $props): self {
|
* @return BelongsTo<Attachment, $this>
|
||||||
/** @var static $createdTenant */
|
*/
|
||||||
$createdTenant = static::query()->create($attributes);
|
public function headerLogo(): BelongsTo
|
||||||
$createdTenant->syncPropValues($props);
|
{
|
||||||
|
return $this->belongsTo(Attachment::class, 'header_logo_id');
|
||||||
|
}
|
||||||
|
|
||||||
return $createdTenant;
|
/**
|
||||||
});
|
* @return BelongsTo<Attachment, $this>
|
||||||
|
*/
|
||||||
return $tenant;
|
public function footerLogo(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Attachment::class, 'footer_logo_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,78 +53,5 @@ class Tenant extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(Product::class, 'tenant_codigo', 'codigo');
|
return $this->hasMany(Product::class, 'tenant_codigo', 'codigo');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<TenantPropValue, $this>
|
|
||||||
*/
|
|
||||||
public function propValues(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(TenantPropValue::class, 'tenant_codigo', 'codigo');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPropValue(TenantProp|string $prop): ?TenantPropValue
|
|
||||||
{
|
|
||||||
$resolvedProp = $this->resolveProp($prop);
|
|
||||||
|
|
||||||
return $this->propValues()
|
|
||||||
->where('tenant_prop_codigo', $resolvedProp->codigo)
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setPropValue(TenantProp|string $prop, mixed $value): TenantPropValue
|
|
||||||
{
|
|
||||||
$this->ensurePropValuesCanBeManaged();
|
|
||||||
|
|
||||||
$resolvedProp = $this->resolveProp($prop);
|
|
||||||
|
|
||||||
return $this->propValues()->updateOrCreate(
|
|
||||||
['tenant_prop_codigo' => $resolvedProp->codigo],
|
|
||||||
['value' => $value],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $props
|
|
||||||
*/
|
|
||||||
public function syncPropValues(array $props): void
|
|
||||||
{
|
|
||||||
foreach ($props as $codigo => $value) {
|
|
||||||
$this->setPropValue((string) $codigo, $value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $attributes
|
|
||||||
* @param array<string, mixed> $props
|
|
||||||
* @return static
|
|
||||||
*/
|
|
||||||
public function updateWithProps(array $attributes, array $props = []): static
|
|
||||||
{
|
|
||||||
unset($attributes['props']);
|
|
||||||
|
|
||||||
DB::transaction(function () use ($attributes, $props): void {
|
|
||||||
$this->update($attributes);
|
|
||||||
$this->syncPropValues($props);
|
|
||||||
});
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function ensurePropValuesCanBeManaged(): void
|
|
||||||
{
|
|
||||||
if (! $this->exists) {
|
|
||||||
throw new LogicException('Cannot manage prop values for an unsaved tenant.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function resolveProp(TenantProp|string $prop): TenantProp
|
|
||||||
{
|
|
||||||
if ($prop instanceof TenantProp) {
|
|
||||||
return $prop;
|
|
||||||
}
|
|
||||||
|
|
||||||
return TenantProp::query()
|
|
||||||
->where('codigo', $prop)
|
|
||||||
->firstOrFail();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Models;
|
|
||||||
|
|
||||||
use App\Domains\Tenant\Support\TenantPropDataType;
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
#[Fillable([
|
|
||||||
'codigo',
|
|
||||||
'nombre',
|
|
||||||
'descripcion',
|
|
||||||
'is_required',
|
|
||||||
'data_type',
|
|
||||||
])]
|
|
||||||
class TenantProp extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'tenant_props';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'is_required' => 'boolean',
|
|
||||||
'data_type' => TenantPropDataType::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<TenantPropValue, $this>
|
|
||||||
*/
|
|
||||||
public function values(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(TenantPropValue::class, 'tenant_prop_codigo', 'codigo');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
#[Fillable([
|
|
||||||
'tenant_codigo',
|
|
||||||
'tenant_prop_codigo',
|
|
||||||
'value',
|
|
||||||
])]
|
|
||||||
class TenantPropValue extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'tenant_prop_values';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Tenant, $this>
|
|
||||||
*/
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<TenantProp, $this>
|
|
||||||
*/
|
|
||||||
public function prop(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(TenantProp::class, 'tenant_prop_codigo', 'codigo');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Tenant\Support\TenantPropDataType;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class StoreTenantPropRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenant_props', 'codigo')],
|
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
|
||||||
'descripcion' => ['nullable', 'string'],
|
|
||||||
'is_required' => ['sometimes', 'boolean'],
|
|
||||||
'data_type' => ['required', Rule::enum(TenantPropDataType::class)],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Domains\Tenant\Requests;
|
namespace App\Domains\Tenant\Requests;
|
||||||
|
|
||||||
use App\Domains\Tenant\Support\TenantPropRules;
|
|
||||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class StoreTenantRequest extends FormRequest
|
class StoreTenantRequest extends FormRequest
|
||||||
@@ -35,6 +36,52 @@ class StoreTenantRequest extends FormRequest
|
|||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
$logoRule = [
|
||||||
|
'required',
|
||||||
|
static function (string $attribute, mixed $value, Closure $fail): void {
|
||||||
|
if ($value instanceof UploadedFile) {
|
||||||
|
$mime = $value->getMimeType();
|
||||||
|
if (! str_starts_with($mime, 'image/')) {
|
||||||
|
$fail("The {$attribute} must be a valid image or SVG.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
if (Str::isUuid($value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$payload = trim($value);
|
||||||
|
$declaredMimeType = null;
|
||||||
|
if (preg_match('/^data:(?<mime>[-\w.+\/]+);base64,(?<data>.+)$/s', $payload, $matches) === 1) {
|
||||||
|
$declaredMimeType = strtolower($matches['mime']);
|
||||||
|
$payload = $matches['data'];
|
||||||
|
}
|
||||||
|
$decoded = base64_decode(preg_replace('/\s+/', '', $payload), true);
|
||||||
|
if ($decoded === false || $decoded === '') {
|
||||||
|
$fail("The {$attribute} must be a valid base64 image or SVG.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||||
|
$mime = $finfo->buffer($decoded);
|
||||||
|
if (! $mime && $declaredMimeType) {
|
||||||
|
$mime = $declaredMimeType;
|
||||||
|
}
|
||||||
|
if (! $mime || ! str_starts_with($mime, 'image/')) {
|
||||||
|
$fail("The {$attribute} must be a valid image or SVG.");
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$fail("The {$attribute} must be a valid image or SVG.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fail("The {$attribute} must be a valid file or base64 string.");
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
|
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
@@ -45,12 +92,17 @@ class StoreTenantRequest extends FormRequest
|
|||||||
$fail("The {$attribute} field must contain a valid domain or URL.");
|
$fail("The {$attribute} field must contain a valid domain or URL.");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'nullable',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
'max:255',
|
'max:255',
|
||||||
Rule::unique('tenants', 'dominio'),
|
Rule::unique('tenants', 'dominio'),
|
||||||
],
|
],
|
||||||
...TenantPropRules::sync(),
|
'primary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||||
|
'secondary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||||
|
'danger_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||||
|
'header_footer_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||||
|
'header_logo' => $logoRule,
|
||||||
|
'footer_logo' => $logoRule,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Tenant\Models\TenantProp;
|
|
||||||
use App\Domains\Tenant\Support\TenantPropDataType;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class UpdateTenantPropRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
/** @var TenantProp|null $tenantProp */
|
|
||||||
$tenantProp = $this->route('tenantProp');
|
|
||||||
|
|
||||||
return [
|
|
||||||
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenant_props', 'codigo')->ignore($tenantProp?->id)],
|
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
|
||||||
'descripcion' => ['nullable', 'string'],
|
|
||||||
'is_required' => ['sometimes', 'boolean'],
|
|
||||||
'data_type' => ['required', Rule::enum(TenantPropDataType::class)],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,9 +4,10 @@ namespace App\Domains\Tenant\Requests;
|
|||||||
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||||
use App\Domains\Tenant\Support\TenantPropRules;
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class UpdateTenantRequest extends FormRequest
|
class UpdateTenantRequest extends FormRequest
|
||||||
@@ -20,15 +21,17 @@ class UpdateTenantRequest extends FormRequest
|
|||||||
|
|
||||||
protected function prepareForValidation(): void
|
protected function prepareForValidation(): void
|
||||||
{
|
{
|
||||||
$rawDomain = $this->input('dominio');
|
if ($this->has('dominio')) {
|
||||||
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
|
$rawDomain = $this->input('dominio');
|
||||||
|
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
|
||||||
|
|
||||||
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
||||||
&& $normalizedDomain === null;
|
&& $normalizedDomain === null;
|
||||||
|
|
||||||
$this->merge([
|
$this->merge([
|
||||||
'dominio' => $normalizedDomain,
|
'dominio' => $normalizedDomain,
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,14 +42,60 @@ class UpdateTenantRequest extends FormRequest
|
|||||||
/** @var Tenant|null $tenant */
|
/** @var Tenant|null $tenant */
|
||||||
$tenant = $this->route('tenant');
|
$tenant = $this->route('tenant');
|
||||||
|
|
||||||
|
$logoRule = [
|
||||||
|
'nullable',
|
||||||
|
static function (string $attribute, mixed $value, Closure $fail): void {
|
||||||
|
if ($value instanceof UploadedFile) {
|
||||||
|
$mime = $value->getMimeType();
|
||||||
|
if (! str_starts_with($mime, 'image/')) {
|
||||||
|
$fail("The {$attribute} must be a valid image or SVG.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
if (Str::isUuid($value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$payload = trim($value);
|
||||||
|
$declaredMimeType = null;
|
||||||
|
if (preg_match('/^data:(?<mime>[-\w.+\/]+);base64,(?<data>.+)$/s', $payload, $matches) === 1) {
|
||||||
|
$declaredMimeType = strtolower($matches['mime']);
|
||||||
|
$payload = $matches['data'];
|
||||||
|
}
|
||||||
|
$decoded = base64_decode(preg_replace('/\s+/', '', $payload), true);
|
||||||
|
if ($decoded === false || $decoded === '') {
|
||||||
|
$fail("The {$attribute} must be a valid base64 image or SVG.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||||
|
$mime = $finfo->buffer($decoded);
|
||||||
|
if (! $mime && $declaredMimeType) {
|
||||||
|
$mime = $declaredMimeType;
|
||||||
|
}
|
||||||
|
if (! $mime || ! str_starts_with($mime, 'image/')) {
|
||||||
|
$fail("The {$attribute} must be a valid image or SVG.");
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$fail("The {$attribute} must be a valid image or SVG.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fail("The {$attribute} must be a valid file or base64 string.");
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'codigo' => [
|
'codigo' => [
|
||||||
'required',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
'max:255',
|
'max:255',
|
||||||
Rule::unique('tenants', 'codigo')->ignore($tenant?->id),
|
Rule::unique('tenants', 'codigo')->ignore($tenant?->id),
|
||||||
],
|
],
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['nullable', 'string', 'max:255'],
|
||||||
'dominio' => [
|
'dominio' => [
|
||||||
'bail',
|
'bail',
|
||||||
function (string $attribute, mixed $value, Closure $fail): void {
|
function (string $attribute, mixed $value, Closure $fail): void {
|
||||||
@@ -59,7 +108,12 @@ class UpdateTenantRequest extends FormRequest
|
|||||||
'max:255',
|
'max:255',
|
||||||
Rule::unique('tenants', 'dominio')->ignore($tenant?->id),
|
Rule::unique('tenants', 'dominio')->ignore($tenant?->id),
|
||||||
],
|
],
|
||||||
...TenantPropRules::sync(),
|
'primary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||||
|
'secondary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||||
|
'danger_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||||
|
'header_footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||||
|
'header_logo' => $logoRule,
|
||||||
|
'footer_logo' => $logoRule,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Resources;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @mixin \App\Domains\Tenant\Models\TenantProp
|
|
||||||
*/
|
|
||||||
class TenantPropResource extends JsonResource
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'codigo' => $this->codigo,
|
|
||||||
'nombre' => $this->nombre,
|
|
||||||
'descripcion' => $this->descripcion,
|
|
||||||
'is_required' => $this->is_required,
|
|
||||||
'data_type' => $this->data_type?->value ?? $this->data_type,
|
|
||||||
'created_at' => $this->created_at,
|
|
||||||
'updated_at' => $this->updated_at,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,29 +10,6 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
|||||||
*/
|
*/
|
||||||
class TenantResource extends JsonResource
|
class TenantResource extends JsonResource
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
protected function collapseProps(): array
|
|
||||||
{
|
|
||||||
return $this->resource->propValues()
|
|
||||||
->with('prop')
|
|
||||||
->get()
|
|
||||||
->mapWithKeys(fn ($propValue): array => [
|
|
||||||
$propValue->tenant_prop_codigo => $this->normalizePropValue($propValue->value, $propValue->prop?->data_type?->value),
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function normalizePropValue(mixed $value, ?string $dataType): mixed
|
|
||||||
{
|
|
||||||
return match ($dataType) {
|
|
||||||
'boolean' => filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? $value,
|
|
||||||
'number' => is_numeric($value) ? $value + 0 : $value,
|
|
||||||
default => $value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -43,7 +20,13 @@ class TenantResource extends JsonResource
|
|||||||
'codigo' => $this->codigo,
|
'codigo' => $this->codigo,
|
||||||
'nombre' => $this->nombre,
|
'nombre' => $this->nombre,
|
||||||
'dominio' => $this->dominio,
|
'dominio' => $this->dominio,
|
||||||
'props' => $this->collapseProps(),
|
'primary_color' => $this->primary_color,
|
||||||
|
'secondary_color' => $this->secondary_color,
|
||||||
|
'danger_color' => $this->danger_color,
|
||||||
|
'header_footer_bg_color' => $this->header_footer_bg_color,
|
||||||
|
// 1 day
|
||||||
|
'header_logo' => $this->headerLogo?->getTemporaryUrl(1440),
|
||||||
|
'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440 ),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
118
app/Domains/Tenant/Services/TenantService.php
Normal file
118
app/Domains/Tenant/Services/TenantService.php
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Tenant\Services;
|
||||||
|
|
||||||
|
use App\Domains\Attachable\Services\AttachmentService;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class TenantService
|
||||||
|
{
|
||||||
|
public function __construct(protected AttachmentService $attachmentService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new tenant and store its logos.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return Tenant
|
||||||
|
*/
|
||||||
|
public function create(array $data): Tenant
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($data): Tenant {
|
||||||
|
$headerLogo = $data['header_logo'] ?? null;
|
||||||
|
$footerLogo = $data['footer_logo'] ?? null;
|
||||||
|
|
||||||
|
unset($data['header_logo'], $data['footer_logo']);
|
||||||
|
|
||||||
|
$headerAttachmentId = null;
|
||||||
|
if ($headerLogo) {
|
||||||
|
$attachment = Str::isUuid($headerLogo)
|
||||||
|
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $headerLogo)->first()
|
||||||
|
: $this->attachmentService->store($headerLogo, 'tenants');
|
||||||
|
|
||||||
|
if ($attachment) {
|
||||||
|
$headerAttachmentId = $attachment->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$footerAttachmentId = null;
|
||||||
|
if ($footerLogo) {
|
||||||
|
$attachment = Str::isUuid($footerLogo)
|
||||||
|
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $footerLogo)->first()
|
||||||
|
: $this->attachmentService->store($footerLogo, 'tenants');
|
||||||
|
|
||||||
|
if ($attachment) {
|
||||||
|
$footerAttachmentId = $attachment->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['header_logo_id'] = $headerAttachmentId;
|
||||||
|
$data['footer_logo_id'] = $footerAttachmentId;
|
||||||
|
|
||||||
|
/** @var Tenant $tenant */
|
||||||
|
$tenant = Tenant::query()->create($data);
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing tenant and store new logos if uploaded.
|
||||||
|
*
|
||||||
|
* @param Tenant $tenant
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return Tenant
|
||||||
|
*/
|
||||||
|
public function update(Tenant $tenant, array $data): Tenant
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $data): Tenant {
|
||||||
|
$hasHeaderLogoKey = array_key_exists('header_logo', $data);
|
||||||
|
$hasFooterLogoKey = array_key_exists('footer_logo', $data);
|
||||||
|
$headerLogo = $data['header_logo'] ?? null;
|
||||||
|
$footerLogo = $data['footer_logo'] ?? null;
|
||||||
|
|
||||||
|
unset($data['header_logo'], $data['footer_logo']);
|
||||||
|
|
||||||
|
$tenant->fill($data);
|
||||||
|
|
||||||
|
if ($hasHeaderLogoKey) {
|
||||||
|
if ($headerLogo) {
|
||||||
|
$attachment = Str::isUuid($headerLogo)
|
||||||
|
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $headerLogo)->first()
|
||||||
|
: $this->attachmentService->store($headerLogo, 'tenants');
|
||||||
|
|
||||||
|
if ($attachment) {
|
||||||
|
$tenant->header_logo_id = $attachment->id;
|
||||||
|
} else {
|
||||||
|
$tenant->header_logo_id = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$tenant->header_logo_id = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasFooterLogoKey) {
|
||||||
|
if ($footerLogo) {
|
||||||
|
$attachment = Str::isUuid($footerLogo)
|
||||||
|
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $footerLogo)->first()
|
||||||
|
: $this->attachmentService->store($footerLogo, 'tenants');
|
||||||
|
|
||||||
|
if ($attachment) {
|
||||||
|
$tenant->footer_logo_id = $attachment->id;
|
||||||
|
} else {
|
||||||
|
$tenant->footer_logo_id = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$tenant->footer_logo_id = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->save();
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Support;
|
|
||||||
|
|
||||||
enum TenantPropDataType: string
|
|
||||||
{
|
|
||||||
case String = 'string';
|
|
||||||
case Number = 'number';
|
|
||||||
case Boolean = 'boolean';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public static function values(): array
|
|
||||||
{
|
|
||||||
return array_column(self::cases(), 'value');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Support;
|
|
||||||
|
|
||||||
use App\Domains\Tenant\Models\TenantProp;
|
|
||||||
use Closure;
|
|
||||||
|
|
||||||
class TenantPropRules
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public static function sync(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'props' => [
|
|
||||||
'sometimes',
|
|
||||||
'array',
|
|
||||||
static function (string $attribute, mixed $value, Closure $fail): void {
|
|
||||||
static::validateExistingProps($attribute, $value, $fail);
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'props.*' => ['nullable'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function validateExistingProps(string $attribute, mixed $value, Closure $fail): void
|
|
||||||
{
|
|
||||||
if (! is_array($value) || $value === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$propCodes = array_map('strval', array_keys($value));
|
|
||||||
$existingPropCodes = TenantProp::query()
|
|
||||||
->whereIn('codigo', $propCodes)
|
|
||||||
->pluck('codigo')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$missingPropCodes = array_values(array_diff($propCodes, $existingPropCodes));
|
|
||||||
|
|
||||||
if ($missingPropCodes === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$fail(sprintf(
|
|
||||||
'The selected %s are invalid for Tenant: %s.',
|
|
||||||
$attribute,
|
|
||||||
implode(', ', $missingPropCodes),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,9 @@
|
|||||||
|
|
||||||
use App\Domains\Tenant\Controllers\BootstrapTenantController;
|
use App\Domains\Tenant\Controllers\BootstrapTenantController;
|
||||||
use App\Domains\Tenant\Controllers\TenantController;
|
use App\Domains\Tenant\Controllers\TenantController;
|
||||||
use App\Domains\Tenant\Controllers\TenantPropController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('tenants/bootstrap/{dominio}', BootstrapTenantController::class)
|
Route::get('tenants/bootstrap/{dominio}', BootstrapTenantController::class)
|
||||||
->where('dominio', '.*');
|
->where('dominio', '.*');
|
||||||
|
|
||||||
Route::apiResource('tenants', TenantController::class);
|
Route::apiResource('tenants', TenantController::class);
|
||||||
Route::apiResource('tenant-props', TenantPropController::class)
|
|
||||||
->parameters(['tenant-props' => 'tenantProp']);
|
|
||||||
|
|||||||
@@ -19,6 +19,22 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
\Illuminate\Database\Eloquent\Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) {
|
||||||
|
/** @var \Illuminate\Database\Eloquent\Builder $this */
|
||||||
|
$perPage = (int) request()->query('per_page', $defaultPerPage);
|
||||||
|
$page = $page ?? (int) request()->query('page', 1);
|
||||||
|
|
||||||
|
if ($perPage <= 0) {
|
||||||
|
$perPage = $defaultPerPage;
|
||||||
|
}
|
||||||
|
if ($perPage > $maxPerPage) {
|
||||||
|
$perPage = $maxPerPage;
|
||||||
|
}
|
||||||
|
if ($page <= 0) {
|
||||||
|
$page = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->paginate(perPage: $perPage, page: $page);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
"laravel/framework": "^13.8",
|
"laravel/framework": "^13.8",
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
"laravel/tinker": "^3.0"
|
"laravel/tinker": "^3.0",
|
||||||
|
"league/flysystem-aws-s3-v3": "3.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|||||||
346
composer.lock
generated
346
composer.lock
generated
@@ -4,8 +4,159 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "bf97dbe15104bcde9dd03530a8a289c8",
|
"content-hash": "c1933c09dc4d90a389d19d7cffff4e43",
|
||||||
"packages": [
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "aws/aws-crt-php",
|
||||||
|
"version": "v1.2.7",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/awslabs/aws-crt-php.git",
|
||||||
|
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||||
|
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=5.5"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
|
||||||
|
"yoast/phpunit-polyfills": "^1.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"classmap": [
|
||||||
|
"src/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"Apache-2.0"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "AWS SDK Common Runtime Team",
|
||||||
|
"email": "aws-sdk-common-runtime@amazon.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "AWS Common Runtime for PHP",
|
||||||
|
"homepage": "https://github.com/awslabs/aws-crt-php",
|
||||||
|
"keywords": [
|
||||||
|
"amazon",
|
||||||
|
"aws",
|
||||||
|
"crt",
|
||||||
|
"sdk"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/awslabs/aws-crt-php/issues",
|
||||||
|
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
|
||||||
|
},
|
||||||
|
"time": "2024-10-18T22:15:13+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "aws/aws-sdk-php",
|
||||||
|
"version": "3.386.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/aws/aws-sdk-php.git",
|
||||||
|
"reference": "e36bc0e97e82d68acc92b9e06f5dc544913d819a"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/e36bc0e97e82d68acc92b9e06f5dc544913d819a",
|
||||||
|
"reference": "e36bc0e97e82d68acc92b9e06f5dc544913d819a",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"aws/aws-crt-php": "^1.2.3",
|
||||||
|
"ext-json": "*",
|
||||||
|
"ext-pcre": "*",
|
||||||
|
"ext-simplexml": "*",
|
||||||
|
"guzzlehttp/guzzle": "^7.4.5",
|
||||||
|
"guzzlehttp/promises": "^2.0",
|
||||||
|
"guzzlehttp/psr7": "^2.4.5",
|
||||||
|
"mtdowling/jmespath.php": "^2.9.1",
|
||||||
|
"php": ">=8.1",
|
||||||
|
"psr/http-message": "^1.0 || ^2.0",
|
||||||
|
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"andrewsville/php-token-reflection": "^1.4",
|
||||||
|
"aws/aws-php-sns-message-validator": "~1.0",
|
||||||
|
"behat/behat": "~3.0",
|
||||||
|
"composer/composer": "^2.7.8",
|
||||||
|
"dms/phpunit-arraysubset-asserts": "^v0.5.0",
|
||||||
|
"doctrine/cache": "~1.4",
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-openssl": "*",
|
||||||
|
"ext-sockets": "*",
|
||||||
|
"phpunit/phpunit": "^10.0",
|
||||||
|
"psr/cache": "^2.0 || ^3.0",
|
||||||
|
"psr/simple-cache": "^2.0 || ^3.0",
|
||||||
|
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
|
||||||
|
"yoast/phpunit-polyfills": "^2.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
|
||||||
|
"doctrine/cache": "To use the DoctrineCacheAdapter",
|
||||||
|
"ext-curl": "To send requests using cURL",
|
||||||
|
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
|
||||||
|
"ext-pcntl": "To use client-side monitoring",
|
||||||
|
"ext-sockets": "To use client-side monitoring"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "3.0-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/functions.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Aws\\": "src/"
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"src/data/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"Apache-2.0"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Amazon Web Services",
|
||||||
|
"homepage": "https://aws.amazon.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
|
||||||
|
"homepage": "https://aws.amazon.com/sdk-for-php",
|
||||||
|
"keywords": [
|
||||||
|
"amazon",
|
||||||
|
"aws",
|
||||||
|
"cloud",
|
||||||
|
"dynamodb",
|
||||||
|
"ec2",
|
||||||
|
"glacier",
|
||||||
|
"s3",
|
||||||
|
"sdk"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"forum": "https://github.com/aws/aws-sdk-php/discussions",
|
||||||
|
"issues": "https://github.com/aws/aws-sdk-php/issues",
|
||||||
|
"source": "https://github.com/aws/aws-sdk-php/tree/3.386.1"
|
||||||
|
},
|
||||||
|
"time": "2026-06-23T01:24:07+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
"version": "0.17.2",
|
"version": "0.17.2",
|
||||||
@@ -1805,6 +1956,62 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-05-14T10:28:08+00:00"
|
"time": "2026-05-14T10:28:08+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "league/flysystem-aws-s3-v3",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
|
||||||
|
"reference": "f8ba6a92a5c1fdcbdd89dede009a1e6e1b93ba8c"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/f8ba6a92a5c1fdcbdd89dede009a1e6e1b93ba8c",
|
||||||
|
"reference": "f8ba6a92a5c1fdcbdd89dede009a1e6e1b93ba8c",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"aws/aws-sdk-php": "^3.132.4",
|
||||||
|
"league/flysystem": "^2.0.0 || ^3.0.0",
|
||||||
|
"league/mime-type-detection": "^1.0.0",
|
||||||
|
"php": "^8.0.2"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"guzzlehttp/guzzle": "<7.0",
|
||||||
|
"guzzlehttp/ringphp": "<1.1.1"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"League\\Flysystem\\AwsS3V3\\": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Frank de Jonge",
|
||||||
|
"email": "info@frankdejonge.nl"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "AWS S3 filesystem adapter for Flysystem.",
|
||||||
|
"keywords": [
|
||||||
|
"Flysystem",
|
||||||
|
"aws",
|
||||||
|
"file",
|
||||||
|
"files",
|
||||||
|
"filesystem",
|
||||||
|
"s3",
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/thephpleague/flysystem-aws-s3-v3/issues",
|
||||||
|
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.0.0"
|
||||||
|
},
|
||||||
|
"time": "2022-01-13T21:11:49+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "league/flysystem-local",
|
"name": "league/flysystem-local",
|
||||||
"version": "3.31.0",
|
"version": "3.31.0",
|
||||||
@@ -2195,6 +2402,72 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-01-02T08:56:05+00:00"
|
"time": "2026-01-02T08:56:05+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "mtdowling/jmespath.php",
|
||||||
|
"version": "2.9.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/jmespath/jmespath.php.git",
|
||||||
|
"reference": "9c208ba27ae7d90853c288b3795d6702eb251d34"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/9c208ba27ae7d90853c288b3795d6702eb251d34",
|
||||||
|
"reference": "9c208ba27ae7d90853c288b3795d6702eb251d34",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.2.5 || ^8.0",
|
||||||
|
"symfony/polyfill-mbstring": "^1.17"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"composer/xdebug-handler": "^3.0.3",
|
||||||
|
"phpunit/phpunit": "^8.5.52"
|
||||||
|
},
|
||||||
|
"bin": [
|
||||||
|
"bin/jp.php"
|
||||||
|
],
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "2.9-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/JmesPath.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"JmesPath\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Graham Campbell",
|
||||||
|
"email": "hello@gjcampbell.co.uk",
|
||||||
|
"homepage": "https://github.com/GrahamCampbell"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Michael Dowling",
|
||||||
|
"email": "mtdowling@gmail.com",
|
||||||
|
"homepage": "https://github.com/mtdowling"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Declaratively specify how to extract elements from a JSON document",
|
||||||
|
"keywords": [
|
||||||
|
"json",
|
||||||
|
"jsonpath"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/jmespath/jmespath.php/issues",
|
||||||
|
"source": "https://github.com/jmespath/jmespath.php/tree/2.9.1"
|
||||||
|
},
|
||||||
|
"time": "2026-06-11T10:43:56+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "nesbot/carbon",
|
"name": "nesbot/carbon",
|
||||||
"version": "3.12.3",
|
"version": "3.12.3",
|
||||||
@@ -3931,6 +4204,77 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-01-05T13:30:16+00:00"
|
"time": "2026-01-05T13:30:16+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/filesystem",
|
||||||
|
"version": "v8.1.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/filesystem.git",
|
||||||
|
"reference": "99aec13b82b4967ec5088222c4a3ecca955949c2"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2",
|
||||||
|
"reference": "99aec13b82b4967ec5088222c4a3ecca955949c2",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.4.1",
|
||||||
|
"symfony/deprecation-contracts": "^2.5|^3",
|
||||||
|
"symfony/polyfill-ctype": "~1.8",
|
||||||
|
"symfony/polyfill-mbstring": "~1.8"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"symfony/process": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\Filesystem\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Provides basic utilities for the filesystem",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/filesystem/tree/v8.1.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-05-29T05:06:50+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/finder",
|
"name": "symfony/finder",
|
||||||
"version": "v8.1.0",
|
"version": "v8.1.0",
|
||||||
|
|||||||
@@ -56,8 +56,11 @@ return [
|
|||||||
'url' => env('AWS_URL'),
|
'url' => env('AWS_URL'),
|
||||||
'endpoint' => env('AWS_ENDPOINT'),
|
'endpoint' => env('AWS_ENDPOINT'),
|
||||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||||
'throw' => false,
|
'http' => [
|
||||||
'report' => false,
|
'verify' => env('AWS_HTTP_VERIFY', false),
|
||||||
|
],
|
||||||
|
'throw' => true,
|
||||||
|
'report' => true,
|
||||||
],
|
],
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ return new class extends Migration
|
|||||||
$table->id();
|
$table->id();
|
||||||
$table->string('codigo')->unique();
|
$table->string('codigo')->unique();
|
||||||
$table->string('nombre');
|
$table->string('nombre');
|
||||||
$table->string('dominio')->nullable();
|
$table->string('dominio');
|
||||||
|
$table->string('primary_color');
|
||||||
|
$table->string('secondary_color');
|
||||||
|
$table->string('danger_color');
|
||||||
|
$table->string('header_footer_bg_color');
|
||||||
|
$table->unsignedBigInteger('header_logo_id');
|
||||||
|
$table->unsignedBigInteger('footer_logo_id');
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
<?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::create('tenant_props', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->string('codigo')->unique();
|
|
||||||
$table->string('nombre');
|
|
||||||
$table->text('descripcion')->nullable();
|
|
||||||
$table->boolean('is_required')->default(false);
|
|
||||||
$table->string('data_type');
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('tenant_props');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<?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::create('tenant_prop_values', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->string('tenant_codigo');
|
|
||||||
$table->string('tenant_prop_codigo');
|
|
||||||
$table->text('value')->nullable();
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->foreign('tenant_codigo')
|
|
||||||
->references('codigo')
|
|
||||||
->on('tenants')
|
|
||||||
->cascadeOnUpdate()
|
|
||||||
->cascadeOnDelete();
|
|
||||||
|
|
||||||
$table->foreign('tenant_prop_codigo')
|
|
||||||
->references('codigo')
|
|
||||||
->on('tenant_props')
|
|
||||||
->cascadeOnUpdate()
|
|
||||||
->cascadeOnDelete();
|
|
||||||
|
|
||||||
$table->unique(['tenant_codigo', 'tenant_prop_codigo']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('tenant_prop_values');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<?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::create('carritos', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnUpdate()->nullOnDelete();
|
|
||||||
$table->string('guest_token')->nullable()->index();
|
|
||||||
$table->enum('status', ['active', 'converted', 'abandoned'])->default('active');
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('carritos');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<?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::create('carrito_items', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('cart_id')->constrained('carritos')->cascadeOnUpdate()->cascadeOnDelete();
|
|
||||||
$table->unsignedBigInteger('producto_variante_id');
|
|
||||||
$table->unsignedInteger('cantidad');
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->foreign('producto_variante_id')
|
|
||||||
->references('id')
|
|
||||||
->on('productos_variantes')
|
|
||||||
->cascadeOnUpdate()
|
|
||||||
->cascadeOnDelete();
|
|
||||||
|
|
||||||
$table->unique(['cart_id', 'producto_variante_id'], 'cart_item_cart_variant_unique');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('carrito_items');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?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::create('attachments', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->uuid('key')->unique();
|
||||||
|
$table->string('path');
|
||||||
|
$table->string('filename');
|
||||||
|
$table->string('type');
|
||||||
|
$table->string('mime_type');
|
||||||
|
$table->string('extension', 20)->nullable();
|
||||||
|
$table->unsignedBigInteger('size')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('type');
|
||||||
|
$table->index('mime_type');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('tenants', function (Blueprint $table) {
|
||||||
|
$table->foreign('header_logo_id')
|
||||||
|
->references('id')
|
||||||
|
->on('attachments')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
|
||||||
|
$table->foreign('footer_logo_id')
|
||||||
|
->references('id')
|
||||||
|
->on('attachments')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tenants', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['header_logo_id']);
|
||||||
|
$table->dropForeign(['footer_logo_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::dropIfExists('attachments');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::table('productos_attributes')
|
||||||
|
->where('type', 'text')
|
||||||
|
->update(['type' => 'string']);
|
||||||
|
|
||||||
|
DB::table('productos_attributes')
|
||||||
|
->where('type', 'numeric')
|
||||||
|
->update(['type' => 'number']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::table('productos_attributes')
|
||||||
|
->where('type', 'string')
|
||||||
|
->update(['type' => 'text']);
|
||||||
|
|
||||||
|
DB::table('productos_attributes')
|
||||||
|
->where('type', 'number')
|
||||||
|
->update(['type' => 'numeric']);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -21,5 +21,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
'name' => 'Test User',
|
'name' => 'Test User',
|
||||||
'email' => 'test@example.com',
|
'email' => 'test@example.com',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->call(TenantSeeder::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
82
database/seeders/TenantSeeder.php
Normal file
82
database/seeders/TenantSeeder.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Tenant\Services\TenantService;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
|
||||||
|
class TenantSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function __construct(protected TenantService $tenantService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Check if tenant 'sonder' already exists and delete it to prevent duplicates
|
||||||
|
$existing = Tenant::query()->where('codigo', 'sonder')->first();
|
||||||
|
if ($existing) {
|
||||||
|
$attachmentService = app(\App\Domains\Attachable\Services\AttachmentService::class);
|
||||||
|
if ($existing->headerLogo) {
|
||||||
|
try {
|
||||||
|
$attachmentService->delete($existing->headerLogo);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Ignore exception on cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($existing->footerLogo && $existing->footer_logo_id !== $existing->header_logo_id) {
|
||||||
|
try {
|
||||||
|
$attachmentService->delete($existing->footerLogo);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Ignore exception on cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$existing->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if domain 'localhost' is already in use by another tenant and delete it
|
||||||
|
$existingDomain = Tenant::query()->where('dominio', 'localhost')->first();
|
||||||
|
if ($existingDomain) {
|
||||||
|
$existingDomain->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
$imagePath = public_path('images/sonder.png');
|
||||||
|
|
||||||
|
if (! file_exists($imagePath)) {
|
||||||
|
throw new \RuntimeException("Image not found at path: {$imagePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$headerLogo = new UploadedFile(
|
||||||
|
$imagePath,
|
||||||
|
'sonder.png',
|
||||||
|
'image/png',
|
||||||
|
null,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
$footerLogo = new UploadedFile(
|
||||||
|
$imagePath,
|
||||||
|
'sonder.png',
|
||||||
|
'image/png',
|
||||||
|
null,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->tenantService->create([
|
||||||
|
'codigo' => 'sonder',
|
||||||
|
'nombre' => 'Sonder',
|
||||||
|
'dominio' => 'localhost',
|
||||||
|
'primary_color' => '#6376F3',
|
||||||
|
'secondary_color' => '#A0A0A0',
|
||||||
|
'danger_color' => '#FF8888',
|
||||||
|
'header_footer_bg_color' => '#313131',
|
||||||
|
'header_logo' => $headerLogo,
|
||||||
|
'footer_logo' => $footerLogo,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,8 +23,8 @@
|
|||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="array"/>
|
||||||
<env name="DB_CONNECTION" value="sqlite"/>
|
<env name="DB_CONNECTION" value="mysql"/>
|
||||||
<env name="DB_DATABASE" value=":memory:"/>
|
<env name="DB_DATABASE" value="shopit_test"/>
|
||||||
<env name="DB_URL" value=""/>
|
<env name="DB_URL" value=""/>
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
|
|||||||
95
postman/attachable-s3-testing.postman_collection.json
Normal file
95
postman/attachable-s3-testing.postman_collection.json
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"_postman_id": "8faefdb8-734a-4262-a3b6-4d49e56ea901",
|
||||||
|
"name": "Storage Test S3",
|
||||||
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
|
},
|
||||||
|
"variable": [
|
||||||
|
{
|
||||||
|
"key": "base_url",
|
||||||
|
"value": "http://127.0.0.1:8000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "expires_in_minutes",
|
||||||
|
"value": "10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "path",
|
||||||
|
"value": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Upload Test File",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [],
|
||||||
|
"body": {
|
||||||
|
"mode": "formdata",
|
||||||
|
"formdata": [
|
||||||
|
{
|
||||||
|
"key": "file",
|
||||||
|
"type": "file",
|
||||||
|
"src": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "directory",
|
||||||
|
"value": "testing/manual",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "expires_in_minutes",
|
||||||
|
"value": "{{expires_in_minutes}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/storage-test/s3/upload",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"storage-test",
|
||||||
|
"s3",
|
||||||
|
"upload"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Sube un archivo al disco s3 y devuelve el path junto con una temporary_url."
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Generate Temporary URL",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/api/storage-test/s3/temporary-url?path={{path}}&expires_in_minutes={{expires_in_minutes}}",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"storage-test",
|
||||||
|
"s3",
|
||||||
|
"temporary-url"
|
||||||
|
],
|
||||||
|
"query": [
|
||||||
|
{
|
||||||
|
"key": "path",
|
||||||
|
"value": "{{path}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "expires_in_minutes",
|
||||||
|
"value": "{{expires_in_minutes}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Genera una URL temporal para un path ya existente en S3."
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
public/images/sonder.png
Normal file
BIN
public/images/sonder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -9,5 +9,6 @@ Route::get('/user', function (Request $request) {
|
|||||||
|
|
||||||
require __DIR__.'/../app/Domains/Catalog/routes/api.php';
|
require __DIR__.'/../app/Domains/Catalog/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Cart/routes/api.php';
|
require __DIR__.'/../app/Domains/Cart/routes/api.php';
|
||||||
|
require __DIR__.'/../app/Domains/StorageTest/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Purchase/routes/api.php';
|
require __DIR__.'/../app/Domains/Purchase/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Tenant/routes/api.php';
|
require __DIR__.'/../app/Domains/Tenant/routes/api.php';
|
||||||
|
|||||||
129
tests/Feature/Attachable/AttachmentTest.php
Normal file
129
tests/Feature/Attachable/AttachmentTest.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Attachable;
|
||||||
|
|
||||||
|
use App\Domains\Attachable\Enums\AttachmentType;
|
||||||
|
use App\Domains\Attachable\Exceptions\AttachmentStorageException;
|
||||||
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
|
use App\Domains\Attachable\Services\AttachmentService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Mockery;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AttachmentTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_uploads_to_s3_before_persisting_the_attachment(): void
|
||||||
|
{
|
||||||
|
Storage::fake('s3');
|
||||||
|
|
||||||
|
$file = UploadedFile::fake()->image('logo.png');
|
||||||
|
|
||||||
|
$attachment = app(AttachmentService::class)->store(
|
||||||
|
$file,
|
||||||
|
'attachments/acme/logo.png',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(AttachmentType::Image, $attachment->type);
|
||||||
|
$this->assertTrue(Str::isUuid($attachment->key));
|
||||||
|
$this->assertSame('logo.png', $attachment->filename);
|
||||||
|
$this->assertSame('attachments/acme/'.$attachment->key, $attachment->path);
|
||||||
|
Storage::disk('s3')->assertExists('attachments/acme/'.$attachment->key);
|
||||||
|
$this->assertDatabaseHas('attachments', [
|
||||||
|
'id' => $attachment->id,
|
||||||
|
'key' => $attachment->key,
|
||||||
|
'path' => 'attachments/acme/'.$attachment->key,
|
||||||
|
'filename' => 'logo.png',
|
||||||
|
'type' => AttachmentType::Image->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$freshAttachment = Attachment::query()->findOrFail($attachment->id);
|
||||||
|
|
||||||
|
$this->assertSame(AttachmentType::Image, $freshAttachment->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_does_not_persist_the_attachment_when_the_s3_upload_fails(): void
|
||||||
|
{
|
||||||
|
$disk = Mockery::mock();
|
||||||
|
Storage::shouldReceive('disk')
|
||||||
|
->once()
|
||||||
|
->with('s3')
|
||||||
|
->andReturn($disk);
|
||||||
|
$disk->shouldReceive('putFileAs')
|
||||||
|
->once()
|
||||||
|
->with('attachments/globex', Mockery::type(UploadedFile::class), Mockery::on(static fn (string $value): bool => Str::isUuid($value)))
|
||||||
|
->andReturn(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
app(AttachmentService::class)->store(
|
||||||
|
UploadedFile::fake()->create('manual.pdf', 10, 'application/pdf'),
|
||||||
|
'attachments/globex/manual.pdf',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->fail('Expected an AttachmentStorageException to be thrown.');
|
||||||
|
} catch (AttachmentStorageException) {
|
||||||
|
$this->assertDatabaseCount('attachments', 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_deletes_from_s3_before_removing_the_database_record(): void
|
||||||
|
{
|
||||||
|
Storage::fake('s3');
|
||||||
|
|
||||||
|
Storage::disk('s3')->put('attachments/initech/spec.pdf', 'spec');
|
||||||
|
|
||||||
|
$attachment = Attachment::query()->create([
|
||||||
|
'path' => 'attachments/initech/spec.pdf',
|
||||||
|
'key' => (string) Str::uuid(),
|
||||||
|
'filename' => 'spec.pdf',
|
||||||
|
'type' => AttachmentType::Pdf,
|
||||||
|
'mime_type' => 'application/pdf',
|
||||||
|
'extension' => 'pdf',
|
||||||
|
'size' => 512,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(AttachmentService::class)->delete($attachment);
|
||||||
|
|
||||||
|
Storage::disk('s3')->assertMissing('attachments/initech/spec.pdf');
|
||||||
|
$this->assertDatabaseMissing('attachments', [
|
||||||
|
'id' => $attachment->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_keeps_the_database_record_when_the_s3_delete_fails(): void
|
||||||
|
{
|
||||||
|
$attachment = Attachment::query()->create([
|
||||||
|
'path' => 'attachments/umbrella/audio.mp3',
|
||||||
|
'key' => (string) Str::uuid(),
|
||||||
|
'filename' => 'audio.mp3',
|
||||||
|
'type' => AttachmentType::Audio,
|
||||||
|
'mime_type' => 'audio/mpeg',
|
||||||
|
'extension' => 'mp3',
|
||||||
|
'size' => 1024,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$disk = Mockery::mock();
|
||||||
|
Storage::shouldReceive('disk')
|
||||||
|
->once()
|
||||||
|
->with('s3')
|
||||||
|
->andReturn($disk);
|
||||||
|
$disk->shouldReceive('delete')
|
||||||
|
->once()
|
||||||
|
->with('attachments/umbrella/audio.mp3')
|
||||||
|
->andReturn(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
app(AttachmentService::class)->delete($attachment);
|
||||||
|
|
||||||
|
$this->fail('Expected an AttachmentStorageException to be thrown.');
|
||||||
|
} catch (AttachmentStorageException) {
|
||||||
|
$this->assertDatabaseHas('attachments', [
|
||||||
|
'id' => $attachment->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,11 +17,7 @@ class CartControllerTest extends TestCase
|
|||||||
|
|
||||||
public function test_it_returns_an_empty_guest_cart_when_cart_does_not_exist(): void
|
public function test_it_returns_an_empty_guest_cart_when_cart_does_not_exist(): void
|
||||||
{
|
{
|
||||||
Tenant::create([
|
$this->createTenant('acme', 'Acme', 'acme.com');
|
||||||
'codigo' => 'acme',
|
|
||||||
'nombre' => 'Acme',
|
|
||||||
'dominio' => 'acme.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->getJson('/api/tenants/acme/cart')
|
$this->getJson('/api/tenants/acme/cart')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
@@ -272,10 +268,10 @@ class CartControllerTest extends TestCase
|
|||||||
string $price,
|
string $price,
|
||||||
string $slugPrefix = 'shirt',
|
string $slugPrefix = 'shirt',
|
||||||
): ProductVariant {
|
): ProductVariant {
|
||||||
Tenant::query()->firstOrCreate(
|
$tenant = Tenant::query()->where('codigo', $tenantCode)->first();
|
||||||
['codigo' => $tenantCode],
|
if (! $tenant) {
|
||||||
['nombre' => ucfirst($tenantCode), 'dominio' => "{$tenantCode}.com"],
|
$this->createTenant($tenantCode, ucfirst($tenantCode), "{$tenantCode}.com");
|
||||||
);
|
}
|
||||||
|
|
||||||
$category = \App\Domains\Catalog\Models\Category::query()->create([
|
$category = \App\Domains\Catalog\Models\Category::query()->create([
|
||||||
'tenant_code' => $tenantCode,
|
'tenant_code' => $tenantCode,
|
||||||
@@ -300,4 +296,37 @@ class CartControllerTest extends TestCase
|
|||||||
'precio' => $price,
|
'precio' => $price,
|
||||||
])->load('product');
|
])->load('product');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant
|
||||||
|
{
|
||||||
|
$hdrKey = (string) \Illuminate\Support\Str::uuid();
|
||||||
|
$ftrKey = (string) \Illuminate\Support\Str::uuid();
|
||||||
|
|
||||||
|
$headerAttachment = \App\Domains\Attachable\Models\Attachment::create([
|
||||||
|
'key' => $hdrKey,
|
||||||
|
'path' => 'tenants/' . $hdrKey . '.png',
|
||||||
|
'filename' => 'logo_header.png',
|
||||||
|
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
]);
|
||||||
|
$footerAttachment = \App\Domains\Attachable\Models\Attachment::create([
|
||||||
|
'key' => $ftrKey,
|
||||||
|
'path' => 'tenants/' . $ftrKey . '.png',
|
||||||
|
'filename' => 'logo_footer.png',
|
||||||
|
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Tenant::create([
|
||||||
|
'codigo' => $codigo,
|
||||||
|
'nombre' => $nombre,
|
||||||
|
'dominio' => $dominio,
|
||||||
|
'primary_color' => '#111111',
|
||||||
|
'secondary_color' => '#222222',
|
||||||
|
'danger_color' => '#333333',
|
||||||
|
'header_footer_bg_color' => '#444444',
|
||||||
|
'header_logo_id' => $headerAttachment->id,
|
||||||
|
'footer_logo_id' => $footerAttachment->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,7 @@ class ProductAttributeControllerTest extends TestCase
|
|||||||
|
|
||||||
public function test_it_creates_a_select_attribute_with_options(): void
|
public function test_it_creates_a_select_attribute_with_options(): void
|
||||||
{
|
{
|
||||||
Tenant::create([
|
$this->createTenant('acme', 'Acme', 'acme.com');
|
||||||
'codigo' => 'acme',
|
|
||||||
'nombre' => 'Acme',
|
|
||||||
'dominio' => 'acme.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/tenants/acme/product-attributes', [
|
$response = $this->postJson('/api/tenants/acme/product-attributes', [
|
||||||
'codigo' => 'color',
|
'codigo' => 'color',
|
||||||
@@ -42,11 +38,11 @@ class ProductAttributeControllerTest extends TestCase
|
|||||||
|
|
||||||
$response
|
$response
|
||||||
->assertCreated()
|
->assertCreated()
|
||||||
->assertJsonPath('tenant_codigo', 'acme')
|
->assertJsonPath('data.tenant_codigo', 'acme')
|
||||||
->assertJsonPath('codigo', 'color')
|
->assertJsonPath('data.codigo', 'color')
|
||||||
->assertJsonPath('type', 'select')
|
->assertJsonPath('data.type', 'select')
|
||||||
->assertJsonPath('options.0.label', 'Red')
|
->assertJsonPath('data.options.0.label', 'Red')
|
||||||
->assertJsonPath('options.1.metadata.hex', '#0000ff');
|
->assertJsonPath('data.options.1.metadata.hex', '#0000ff');
|
||||||
|
|
||||||
$this->assertDatabaseHas('productos_attributes', [
|
$this->assertDatabaseHas('productos_attributes', [
|
||||||
'tenant_codigo' => 'acme',
|
'tenant_codigo' => 'acme',
|
||||||
@@ -60,18 +56,14 @@ class ProductAttributeControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_rejects_options_for_text_attributes(): void
|
public function test_it_rejects_options_for_string_attributes(): void
|
||||||
{
|
{
|
||||||
Tenant::create([
|
$this->createTenant('acme', 'Acme', 'acme.com');
|
||||||
'codigo' => 'acme',
|
|
||||||
'nombre' => 'Acme',
|
|
||||||
'dominio' => 'acme.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/tenants/acme/product-attributes', [
|
$response = $this->postJson('/api/tenants/acme/product-attributes', [
|
||||||
'codigo' => 'material',
|
'codigo' => 'material',
|
||||||
'nombre' => 'Material',
|
'nombre' => 'Material',
|
||||||
'type' => 'text',
|
'type' => 'string',
|
||||||
'options' => [
|
'options' => [
|
||||||
['label' => 'Cotton'],
|
['label' => 'Cotton'],
|
||||||
],
|
],
|
||||||
@@ -81,4 +73,37 @@ class ProductAttributeControllerTest extends TestCase
|
|||||||
->assertUnprocessable()
|
->assertUnprocessable()
|
||||||
->assertJsonValidationErrors(['options']);
|
->assertJsonValidationErrors(['options']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant
|
||||||
|
{
|
||||||
|
$hdrKey = (string) \Illuminate\Support\Str::uuid();
|
||||||
|
$ftrKey = (string) \Illuminate\Support\Str::uuid();
|
||||||
|
|
||||||
|
$headerAttachment = \App\Domains\Attachable\Models\Attachment::create([
|
||||||
|
'key' => $hdrKey,
|
||||||
|
'path' => 'tenants/' . $hdrKey . '.png',
|
||||||
|
'filename' => 'logo_header.png',
|
||||||
|
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
]);
|
||||||
|
$footerAttachment = \App\Domains\Attachable\Models\Attachment::create([
|
||||||
|
'key' => $ftrKey,
|
||||||
|
'path' => 'tenants/' . $ftrKey . '.png',
|
||||||
|
'filename' => 'logo_footer.png',
|
||||||
|
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Tenant::create([
|
||||||
|
'codigo' => $codigo,
|
||||||
|
'nombre' => $nombre,
|
||||||
|
'dominio' => $dominio,
|
||||||
|
'primary_color' => '#111111',
|
||||||
|
'secondary_color' => '#222222',
|
||||||
|
'danger_color' => '#333333',
|
||||||
|
'header_footer_bg_color' => '#444444',
|
||||||
|
'header_logo_id' => $headerAttachment->id,
|
||||||
|
'footer_logo_id' => $footerAttachment->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,44 +3,77 @@
|
|||||||
namespace Tests\Feature\Tenant;
|
namespace Tests\Feature\Tenant;
|
||||||
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Tenant\Models\TenantProp;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class BootstrapTenantControllerTest extends TestCase
|
class BootstrapTenantControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
public function test_it_bootstraps_a_tenant_by_domain_and_includes_props(): void
|
public function test_it_bootstraps_a_tenant_by_domain(): void
|
||||||
{
|
{
|
||||||
|
$hdrKey = (string) Str::uuid();
|
||||||
|
$ftrKey = (string) Str::uuid();
|
||||||
|
|
||||||
|
$headerAttachment = \App\Domains\Attachable\Models\Attachment::create([
|
||||||
|
'key' => $hdrKey,
|
||||||
|
'path' => 'tenants/' . $hdrKey . '.png',
|
||||||
|
'filename' => 'logo_header.png',
|
||||||
|
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
]);
|
||||||
|
$footerAttachment = \App\Domains\Attachable\Models\Attachment::create([
|
||||||
|
'key' => $ftrKey,
|
||||||
|
'path' => 'tenants/' . $ftrKey . '.png',
|
||||||
|
'filename' => 'logo_footer.png',
|
||||||
|
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
]);
|
||||||
|
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::create([
|
||||||
'codigo' => 'acme',
|
'codigo' => 'acme',
|
||||||
'nombre' => 'Acme',
|
'nombre' => 'Acme',
|
||||||
'dominio' => 'acme.com',
|
'dominio' => 'acme.com',
|
||||||
|
'primary_color' => '#ff0000',
|
||||||
|
'secondary_color' => '#00ff00',
|
||||||
|
'danger_color' => '#0000ff',
|
||||||
|
'header_footer_bg_color' => '#ffffff',
|
||||||
|
'header_logo_id' => $headerAttachment->id,
|
||||||
|
'footer_logo_id' => $footerAttachment->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
TenantProp::create([
|
|
||||||
'codigo' => 'primary_color',
|
|
||||||
'nombre' => 'Primary Color',
|
|
||||||
'descripcion' => 'Brand color',
|
|
||||||
'is_required' => false,
|
|
||||||
'data_type' => 'string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tenant->setPropValue('primary_color', 'blue');
|
|
||||||
|
|
||||||
$response = $this->getJson('/api/tenants/bootstrap/acme.com');
|
$response = $this->getJson('/api/tenants/bootstrap/acme.com');
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('codigo', 'acme')
|
->assertJsonPath('data.codigo', 'acme')
|
||||||
->assertJsonPath('dominio', 'acme.com')
|
->assertJsonPath('data.dominio', 'acme.com')
|
||||||
->assertJsonPath('props.primary_color', 'blue');
|
->assertJsonPath('data.primary_color', '#ff0000')
|
||||||
|
->assertJsonPath('data.secondary_color', '#00ff00')
|
||||||
|
->assertJsonPath('data.danger_color', '#0000ff')
|
||||||
|
->assertJsonPath('data.header_footer_bg_color', '#ffffff');
|
||||||
|
|
||||||
|
$headerUrl = $response->json('data.header_logo');
|
||||||
|
$footerUrl = $response->json('data.footer_logo');
|
||||||
|
|
||||||
|
$this->assertStringContainsString($headerAttachment->key, $headerUrl);
|
||||||
|
$this->assertStringContainsString($footerAttachment->key, $footerUrl);
|
||||||
|
$this->assertTrue(
|
||||||
|
str_contains($headerUrl, 'Expires=') || str_contains($headerUrl, 'expiration=') || str_contains($headerUrl, 'X-Amz-Expires=')
|
||||||
|
);
|
||||||
|
$this->assertTrue(
|
||||||
|
str_contains($footerUrl, 'Expires=') || str_contains($footerUrl, 'expiration=') || str_contains($footerUrl, 'X-Amz-Expires=')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('props', $response->json('data'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_bootstraps_a_tenant_from_a_full_url(): void
|
public function test_it_bootstraps_a_tenant_from_a_full_url(): void
|
||||||
{
|
{
|
||||||
Tenant::create([
|
$this->createTenant([
|
||||||
'codigo' => 'acme',
|
'codigo' => 'acme',
|
||||||
'nombre' => 'Acme',
|
'nombre' => 'Acme',
|
||||||
'dominio' => 'acme.com',
|
'dominio' => 'acme.com',
|
||||||
@@ -52,8 +85,8 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
|
|
||||||
$response
|
$response
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('codigo', 'acme')
|
->assertJsonPath('data.codigo', 'acme')
|
||||||
->assertJsonPath('dominio', 'acme.com');
|
->assertJsonPath('data.dominio', 'acme.com');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_returns_not_found_when_the_domain_does_not_exist(): void
|
public function test_it_returns_not_found_when_the_domain_does_not_exist(): void
|
||||||
@@ -65,38 +98,65 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
|
|
||||||
public function test_it_rejects_duplicate_domains_after_normalization_when_storing(): void
|
public function test_it_rejects_duplicate_domains_after_normalization_when_storing(): void
|
||||||
{
|
{
|
||||||
TenantProp::create([
|
$base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
'codigo' => 'primary_color',
|
|
||||||
'nombre' => 'Primary Color',
|
|
||||||
'descripcion' => 'Brand color',
|
|
||||||
'is_required' => false,
|
|
||||||
'data_type' => 'string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$firstResponse = $this->postJson('/api/tenants', [
|
$firstResponse = $this->postJson('/api/tenants', [
|
||||||
'codigo' => 'acme',
|
'codigo' => 'acme',
|
||||||
'nombre' => 'Acme',
|
'nombre' => 'Acme',
|
||||||
'dominio' => 'https://ACME.com/path',
|
'dominio' => 'https://ACME.com/path',
|
||||||
'props' => [
|
'primary_color' => '#111111',
|
||||||
'primary_color' => 'blue',
|
'secondary_color' => '#222222',
|
||||||
],
|
'danger_color' => '#333333',
|
||||||
|
'header_footer_bg_color' => '#444444',
|
||||||
|
'header_logo' => $base64Image,
|
||||||
|
'footer_logo' => $base64Image,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$firstResponse
|
$firstResponse
|
||||||
->assertCreated()
|
->assertCreated()
|
||||||
->assertJsonPath('dominio', 'acme.com')
|
->assertJsonPath('data.dominio', 'acme.com')
|
||||||
->assertJsonPath('props.primary_color', 'blue');
|
->assertJsonPath('data.primary_color', '#111111')
|
||||||
|
->assertJsonPath('data.secondary_color', '#222222')
|
||||||
|
->assertJsonPath('data.danger_color', '#333333')
|
||||||
|
->assertJsonPath('data.header_footer_bg_color', '#444444');
|
||||||
|
|
||||||
$this->assertDatabaseHas('tenant_prop_values', [
|
$tenant = Tenant::query()->with(['headerLogo', 'footerLogo'])->where('codigo', 'acme')->firstOrFail();
|
||||||
'tenant_codigo' => 'acme',
|
|
||||||
'tenant_prop_codigo' => 'primary_color',
|
$this->assertNotNull($tenant->header_logo_id);
|
||||||
'value' => 'blue',
|
$this->assertNotNull($tenant->footer_logo_id);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tenants', [
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'primary_color' => '#111111',
|
||||||
|
'secondary_color' => '#222222',
|
||||||
|
'danger_color' => '#333333',
|
||||||
|
'header_footer_bg_color' => '#444444',
|
||||||
|
'header_logo_id' => $tenant->header_logo_id,
|
||||||
|
'footer_logo_id' => $tenant->footer_logo_id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$headerUrl = $firstResponse->json('data.header_logo');
|
||||||
|
$footerUrl = $firstResponse->json('data.footer_logo');
|
||||||
|
|
||||||
|
$this->assertStringContainsString($tenant->headerLogo->key, $headerUrl);
|
||||||
|
$this->assertStringContainsString($tenant->footerLogo->key, $footerUrl);
|
||||||
|
$this->assertTrue(
|
||||||
|
str_contains($headerUrl, 'Expires=') || str_contains($headerUrl, 'expiration=') || str_contains($headerUrl, 'X-Amz-Expires=')
|
||||||
|
);
|
||||||
|
$this->assertTrue(
|
||||||
|
str_contains($footerUrl, 'Expires=') || str_contains($footerUrl, 'expiration=') || str_contains($footerUrl, 'X-Amz-Expires=')
|
||||||
|
);
|
||||||
|
|
||||||
$secondResponse = $this->postJson('/api/tenants', [
|
$secondResponse = $this->postJson('/api/tenants', [
|
||||||
'codigo' => 'globex',
|
'codigo' => 'globex',
|
||||||
'nombre' => 'Globex',
|
'nombre' => 'Globex',
|
||||||
'dominio' => 'acme.com',
|
'dominio' => 'acme.com',
|
||||||
|
'primary_color' => '#111111',
|
||||||
|
'secondary_color' => '#222222',
|
||||||
|
'danger_color' => '#333333',
|
||||||
|
'header_footer_bg_color' => '#444444',
|
||||||
|
'header_logo' => (string) Str::uuid(),
|
||||||
|
'footer_logo' => (string) Str::uuid(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$secondResponse
|
$secondResponse
|
||||||
@@ -106,42 +166,80 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
|
|
||||||
public function test_it_allows_keeping_the_same_domain_on_update_but_rejects_collisions(): void
|
public function test_it_allows_keeping_the_same_domain_on_update_but_rejects_collisions(): void
|
||||||
{
|
{
|
||||||
TenantProp::create([
|
$tenant = $this->createTenant([
|
||||||
'codigo' => 'primary_color',
|
|
||||||
'nombre' => 'Primary Color',
|
|
||||||
'descripcion' => 'Brand color',
|
|
||||||
'is_required' => false,
|
|
||||||
'data_type' => 'string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tenant = Tenant::create([
|
|
||||||
'codigo' => 'acme',
|
'codigo' => 'acme',
|
||||||
'nombre' => 'Acme',
|
'nombre' => 'Acme',
|
||||||
'dominio' => 'acme.com',
|
'dominio' => 'acme.com',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$otherTenant = Tenant::create([
|
$otherTenant = $this->createTenant([
|
||||||
'codigo' => 'globex',
|
'codigo' => 'globex',
|
||||||
'nombre' => 'Globex',
|
'nombre' => 'Globex',
|
||||||
'dominio' => 'globex.com',
|
'dominio' => 'globex.com',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$successfulResponse = $this->putJson("/api/tenants/{$tenant->id}", [
|
$hdrUuid = (string) Str::uuid();
|
||||||
|
$ftrUuid = (string) Str::uuid();
|
||||||
|
|
||||||
|
$hdrAttachment = \App\Domains\Attachable\Models\Attachment::create([
|
||||||
|
'key' => $hdrUuid,
|
||||||
|
'path' => 'tenants/' . $hdrUuid . '.png',
|
||||||
|
'filename' => 'hdr.png',
|
||||||
|
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
]);
|
||||||
|
$ftrAttachment = \App\Domains\Attachable\Models\Attachment::create([
|
||||||
|
'key' => $ftrUuid,
|
||||||
|
'path' => 'tenants/' . $ftrUuid . '.png',
|
||||||
|
'filename' => 'ftr.png',
|
||||||
|
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$successfulResponse = $this->putJson("/api/tenants/{$tenant->codigo}", [
|
||||||
'codigo' => 'acme',
|
'codigo' => 'acme',
|
||||||
'nombre' => 'Acme Updated',
|
'nombre' => 'Acme Updated',
|
||||||
'dominio' => 'https://ACME.com:443/admin',
|
'dominio' => 'https://ACME.com:443/admin',
|
||||||
'props' => [
|
'primary_color' => '#555555',
|
||||||
'primary_color' => 'green',
|
'secondary_color' => '#666666',
|
||||||
],
|
'danger_color' => '#777777',
|
||||||
|
'header_footer_bg_color' => '#888888',
|
||||||
|
'header_logo' => $hdrUuid,
|
||||||
|
'footer_logo' => $ftrUuid,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$successfulResponse
|
$successfulResponse
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('nombre', 'Acme Updated')
|
->assertJsonPath('data.nombre', 'Acme Updated')
|
||||||
->assertJsonPath('dominio', 'acme.com')
|
->assertJsonPath('data.dominio', 'acme.com')
|
||||||
->assertJsonPath('props.primary_color', 'green');
|
->assertJsonPath('data.primary_color', '#555555')
|
||||||
|
->assertJsonPath('data.secondary_color', '#666666')
|
||||||
|
->assertJsonPath('data.danger_color', '#777777')
|
||||||
|
->assertJsonPath('data.header_footer_bg_color', '#888888');
|
||||||
|
|
||||||
$failingResponse = $this->putJson("/api/tenants/{$otherTenant->id}", [
|
$headerUrl = $successfulResponse->json('data.header_logo');
|
||||||
|
$footerUrl = $successfulResponse->json('data.footer_logo');
|
||||||
|
|
||||||
|
$this->assertStringContainsString($hdrUuid, $headerUrl);
|
||||||
|
$this->assertStringContainsString($ftrUuid, $footerUrl);
|
||||||
|
$this->assertTrue(
|
||||||
|
str_contains($headerUrl, 'Expires=') || str_contains($headerUrl, 'expiration=') || str_contains($headerUrl, 'X-Amz-Expires=')
|
||||||
|
);
|
||||||
|
$this->assertTrue(
|
||||||
|
str_contains($footerUrl, 'Expires=') || str_contains($footerUrl, 'expiration=') || str_contains($footerUrl, 'X-Amz-Expires=')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tenants', [
|
||||||
|
'id' => $tenant->id,
|
||||||
|
'primary_color' => '#555555',
|
||||||
|
'secondary_color' => '#666666',
|
||||||
|
'danger_color' => '#777777',
|
||||||
|
'header_footer_bg_color' => '#888888',
|
||||||
|
'header_logo_id' => $hdrAttachment->id,
|
||||||
|
'footer_logo_id' => $ftrAttachment->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$failingResponse = $this->putJson("/api/tenants/{$otherTenant->codigo}", [
|
||||||
'codigo' => 'globex',
|
'codigo' => 'globex',
|
||||||
'nombre' => 'Globex',
|
'nombre' => 'Globex',
|
||||||
'dominio' => 'https://ACME.com/storefront',
|
'dominio' => 'https://ACME.com/storefront',
|
||||||
@@ -151,4 +249,213 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
->assertUnprocessable()
|
->assertUnprocessable()
|
||||||
->assertJsonValidationErrors(['dominio']);
|
->assertJsonValidationErrors(['dominio']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_allows_partial_update_without_required_fields(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant([
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'acme.com',
|
||||||
|
'primary_color' => '#ffffff',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->putJson("/api/tenants/{$tenant->codigo}", [
|
||||||
|
'primary_color' => '#000000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.codigo', 'acme')
|
||||||
|
->assertJsonPath('data.nombre', 'Acme')
|
||||||
|
->assertJsonPath('data.primary_color', '#000000');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tenants', [
|
||||||
|
'id' => $tenant->id,
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'primary_color' => '#000000',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_validates_aesthetic_colors(): void
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/tenants', [
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'acme.com',
|
||||||
|
'primary_color' => 'invalid-color',
|
||||||
|
'secondary_color' => '#222222',
|
||||||
|
'danger_color' => '#333333',
|
||||||
|
'header_footer_bg_color' => '#444444',
|
||||||
|
'header_logo' => (string) Str::uuid(),
|
||||||
|
'footer_logo' => (string) Str::uuid(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertJsonValidationErrors(['primary_color']);
|
||||||
|
|
||||||
|
$response2 = $this->postJson('/api/tenants', [
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'acme.com',
|
||||||
|
'primary_color' => '#12345',
|
||||||
|
'secondary_color' => '#222222',
|
||||||
|
'danger_color' => '#333333',
|
||||||
|
'header_footer_bg_color' => '#444444',
|
||||||
|
'header_logo' => (string) Str::uuid(),
|
||||||
|
'footer_logo' => (string) Str::uuid(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response2->assertJsonValidationErrors(['primary_color']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_validates_logo_must_be_image_or_svg(): void
|
||||||
|
{
|
||||||
|
Storage::fake('s3');
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/tenants', [
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'acme.com',
|
||||||
|
'primary_color' => '#111111',
|
||||||
|
'secondary_color' => '#222222',
|
||||||
|
'danger_color' => '#333333',
|
||||||
|
'header_footer_bg_color' => '#444444',
|
||||||
|
'header_logo' => UploadedFile::fake()->create('document.pdf', 10, 'application/pdf'),
|
||||||
|
'footer_logo' => (string) Str::uuid(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertJsonValidationErrors(['header_logo']);
|
||||||
|
|
||||||
|
$response2 = $this->postJson('/api/tenants', [
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'acme.com',
|
||||||
|
'primary_color' => '#111111',
|
||||||
|
'secondary_color' => '#222222',
|
||||||
|
'danger_color' => '#333333',
|
||||||
|
'header_footer_bg_color' => '#444444',
|
||||||
|
'header_logo' => 'data:application/pdf;base64,JVBERi0xLjQKJdcfqksKMSAwIG9iagogIDw8IC9UeXBlIC9DYXRhbG9nCiAgICAvUGFnZXMgMiAwIFI...',
|
||||||
|
'footer_logo' => (string) Str::uuid(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response2->assertJsonValidationErrors(['header_logo']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_stores_uploaded_file_logos_in_tenants_directory(): void
|
||||||
|
{
|
||||||
|
Storage::fake('s3');
|
||||||
|
|
||||||
|
$header = UploadedFile::fake()->image('header.png');
|
||||||
|
$footer = UploadedFile::fake()->image('footer.svg', 100, 100);
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/tenants', [
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'acme.com',
|
||||||
|
'primary_color' => '#111111',
|
||||||
|
'secondary_color' => '#222222',
|
||||||
|
'danger_color' => '#333333',
|
||||||
|
'header_footer_bg_color' => '#444444',
|
||||||
|
'header_logo' => $header,
|
||||||
|
'footer_logo' => $footer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertCreated();
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->with(['headerLogo', 'footerLogo'])->where('codigo', 'acme')->firstOrFail();
|
||||||
|
|
||||||
|
$this->assertNotNull($tenant->header_logo_id);
|
||||||
|
$this->assertNotNull($tenant->footer_logo_id);
|
||||||
|
|
||||||
|
$headerUrl = $response->json('data.header_logo');
|
||||||
|
$footerUrl = $response->json('data.footer_logo');
|
||||||
|
|
||||||
|
$this->assertStringContainsString($tenant->headerLogo->key, $headerUrl);
|
||||||
|
$this->assertStringContainsString($tenant->footerLogo->key, $footerUrl);
|
||||||
|
$this->assertTrue(
|
||||||
|
str_contains($headerUrl, 'Expires=') || str_contains($headerUrl, 'expiration=') || str_contains($headerUrl, 'X-Amz-Expires=')
|
||||||
|
);
|
||||||
|
$this->assertTrue(
|
||||||
|
str_contains($footerUrl, 'Expires=') || str_contains($footerUrl, 'expiration=') || str_contains($footerUrl, 'X-Amz-Expires=')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('attachments', ['key' => $tenant->headerLogo->key]);
|
||||||
|
$this->assertDatabaseHas('attachments', ['key' => $tenant->footerLogo->key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTenant(array $attributes = []): Tenant
|
||||||
|
{
|
||||||
|
$hdrKey = (string) Str::uuid();
|
||||||
|
$ftrKey = (string) Str::uuid();
|
||||||
|
|
||||||
|
$headerAttachment = \App\Domains\Attachable\Models\Attachment::create([
|
||||||
|
'key' => $hdrKey,
|
||||||
|
'path' => 'tenants/' . $hdrKey . '.png',
|
||||||
|
'filename' => 'logo_header.png',
|
||||||
|
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
]);
|
||||||
|
$footerAttachment = \App\Domains\Attachable\Models\Attachment::create([
|
||||||
|
'key' => $ftrKey,
|
||||||
|
'path' => 'tenants/' . $ftrKey . '.png',
|
||||||
|
'filename' => 'logo_footer.png',
|
||||||
|
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Tenant::create(array_merge([
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'acme.com',
|
||||||
|
'primary_color' => '#ff0000',
|
||||||
|
'secondary_color' => '#00ff00',
|
||||||
|
'danger_color' => '#0000ff',
|
||||||
|
'header_footer_bg_color' => '#ffffff',
|
||||||
|
'header_logo_id' => $headerAttachment->id,
|
||||||
|
'footer_logo_id' => $footerAttachment->id,
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_stores_base64_logos_in_tenants_directory(): void
|
||||||
|
{
|
||||||
|
Storage::fake('s3');
|
||||||
|
|
||||||
|
$base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
$ftrUuid = (string) Str::uuid();
|
||||||
|
$footerAttachment = \App\Domains\Attachable\Models\Attachment::create([
|
||||||
|
'key' => $ftrUuid,
|
||||||
|
'path' => 'tenants/' . $ftrUuid . '.png',
|
||||||
|
'filename' => 'logo_footer.png',
|
||||||
|
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/tenants', [
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'acme.com',
|
||||||
|
'primary_color' => '#111111',
|
||||||
|
'secondary_color' => '#222222',
|
||||||
|
'danger_color' => '#333333',
|
||||||
|
'header_footer_bg_color' => '#444444',
|
||||||
|
'header_logo' => $base64Image,
|
||||||
|
'footer_logo' => $ftrUuid,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertCreated();
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->with('headerLogo')->where('codigo', 'acme')->firstOrFail();
|
||||||
|
|
||||||
|
$this->assertNotNull($tenant->header_logo_id);
|
||||||
|
|
||||||
|
$headerUrl = $response->json('data.header_logo');
|
||||||
|
|
||||||
|
$this->assertStringContainsString($tenant->headerLogo->key, $headerUrl);
|
||||||
|
$this->assertTrue(
|
||||||
|
str_contains($headerUrl, 'Expires=') || str_contains($headerUrl, 'expiration=') || str_contains($headerUrl, 'X-Amz-Expires=')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('attachments', ['key' => $tenant->headerLogo->key]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Tenant;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class TenantPropControllerTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_it_creates_a_tenant_prop(): void
|
|
||||||
{
|
|
||||||
$response = $this->postJson('/api/tenant-props', [
|
|
||||||
'codigo' => 'primary_color',
|
|
||||||
'nombre' => 'Primary Color',
|
|
||||||
'descripcion' => 'Brand color',
|
|
||||||
'is_required' => false,
|
|
||||||
'data_type' => 'string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertCreated()
|
|
||||||
->assertJsonPath('codigo', 'primary_color')
|
|
||||||
->assertJsonPath('data_type', 'string');
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('tenant_props', [
|
|
||||||
'codigo' => 'primary_color',
|
|
||||||
'data_type' => 'string',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_rejects_unknown_data_type(): void
|
|
||||||
{
|
|
||||||
$response = $this->postJson('/api/tenant-props', [
|
|
||||||
'codigo' => 'primary_color',
|
|
||||||
'nombre' => 'Primary Color',
|
|
||||||
'data_type' => 'unsupported',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertUnprocessable()
|
|
||||||
->assertJsonValidationErrors(['data_type']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user