Compare commits
38 Commits
separacion
...
24235c5ca9
| Author | SHA1 | Date | |
|---|---|---|---|
| 24235c5ca9 | |||
| 49771ebb13 | |||
| cd420e02d8 | |||
| 0206ee2985 | |||
| 9b7d728117 | |||
| b5b57a753c | |||
| 18504594eb | |||
| 9d3ed17c00 | |||
| 74e443ece1 | |||
| 6efda4727f | |||
| b0508d79b3 | |||
| 67bc9e6cb4 | |||
| 4125d8353d | |||
| 31b65eb676 | |||
| 8708cea122 | |||
| 54aa696145 | |||
| 1f9d876bc3 | |||
| 0dae985230 | |||
| 4c237fb729 | |||
| edb4c128d6 | |||
| 2ce21799c7 | |||
| 17799122e2 | |||
| e4448d810e | |||
| 7994fe0c96 | |||
| ee59236d55 | |||
| aec92d5346 | |||
| 6db98ddc1a | |||
| 56b87f0c12 | |||
| 9c5a29d933 | |||
| 049484dee5 | |||
| 3360e81e96 | |||
| 93aead8dbf | |||
| 740566b02d | |||
| b874a45724 | |||
| 6c45f45815 | |||
| 70f51ac863 | |||
| 492c6c4ac2 | |||
| a5b1539a80 |
@@ -56,10 +56,13 @@ MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
|
||||
AWS_ENDPOINT=
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_DEFAULT_REGION=
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=
|
||||
AWS_HTTP_VERIFY=
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
66
app/Domains/Cart/Controllers/CartController.php
Normal file
66
app/Domains/Cart/Controllers/CartController.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Cart\Controllers;
|
||||
|
||||
use App\Domains\Cart\Requests\AddCartItemRequest;
|
||||
use App\Domains\Cart\Requests\UpdateCartItemQuantityRequest;
|
||||
use App\Domains\Cart\Resources\CartResource;
|
||||
use App\Domains\Cart\Services\CartService;
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CartController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected CartService $cartService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function show(Request $request, Tenant $tenant): CartResource
|
||||
{
|
||||
return CartResource::make($this->cartService->show($tenant, $request));
|
||||
}
|
||||
|
||||
public function addItem(AddCartItemRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
$result = $this->cartService->addItem(
|
||||
$tenant,
|
||||
$request,
|
||||
(int) $request->validated('product_variant_id'),
|
||||
(int) $request->validated('cantidad'),
|
||||
);
|
||||
|
||||
$response = CartResource::make($result['cart'])->response();
|
||||
|
||||
if ($result['guest_token'] !== null) {
|
||||
$response->withCookie($this->cartService->makeGuestTokenCookie($result['guest_token']));
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function updateItemQuantity(
|
||||
UpdateCartItemQuantityRequest $request,
|
||||
Tenant $tenant,
|
||||
ProductVariant $productVariant,
|
||||
): CartResource {
|
||||
return CartResource::make(
|
||||
$this->cartService->updateItemQuantity(
|
||||
$tenant,
|
||||
$request,
|
||||
$productVariant->getKey(),
|
||||
(int) $request->validated('cantidad'),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function removeItem(Request $request, Tenant $tenant, ProductVariant $productVariant): CartResource
|
||||
{
|
||||
return CartResource::make(
|
||||
$this->cartService->removeItem($tenant, $request, $productVariant->getKey())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Cart\Models;
|
||||
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -11,8 +12,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_codigo',
|
||||
'user_id',
|
||||
'guest_token',
|
||||
'status',
|
||||
@@ -30,6 +33,14 @@ class Cart extends Model
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
@@ -54,11 +65,8 @@ class Cart extends Model
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($productVariantId, $quantity) {
|
||||
/** @var ProductVariant $variant */
|
||||
$variant = ProductVariant::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($productVariantId);
|
||||
return DB::transaction(function () use ($productVariantId, $quantity): CartItem {
|
||||
$variant = $this->resolveScopedVariant($productVariantId, true);
|
||||
|
||||
if ($variant->stock < $quantity) {
|
||||
throw ValidationException::withMessages([
|
||||
@@ -68,18 +76,18 @@ class Cart extends Model
|
||||
|
||||
/** @var CartItem|null $item */
|
||||
$item = $this->items()
|
||||
->where('producto_variante_id', $productVariantId)
|
||||
->where('producto_variante_id', $variant->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($item) {
|
||||
$item->cantidad += $quantity;
|
||||
$item->save();
|
||||
} else {
|
||||
if ($item === null) {
|
||||
$item = $this->items()->create([
|
||||
'producto_variante_id' => $productVariantId,
|
||||
'producto_variante_id' => $variant->getKey(),
|
||||
'cantidad' => $quantity,
|
||||
]);
|
||||
} else {
|
||||
$item->cantidad += $quantity;
|
||||
$item->save();
|
||||
}
|
||||
|
||||
$variant->decrement('stock', $quantity);
|
||||
@@ -96,18 +104,14 @@ class Cart extends Model
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($productVariantId, $quantity) {
|
||||
return DB::transaction(function () use ($productVariantId, $quantity): CartItem {
|
||||
/** @var CartItem $item */
|
||||
$item = $this->items()
|
||||
->where('producto_variante_id', $productVariantId)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
/** @var ProductVariant $variant */
|
||||
$variant = ProductVariant::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($productVariantId);
|
||||
|
||||
$variant = $this->resolveScopedVariant($productVariantId, true);
|
||||
$delta = $quantity - $item->cantidad;
|
||||
|
||||
if ($delta > 0 && $variant->stock < $delta) {
|
||||
@@ -133,20 +137,36 @@ class Cart extends Model
|
||||
|
||||
public function removeItem(int $productVariantId): void
|
||||
{
|
||||
DB::transaction(function () use ($productVariantId) {
|
||||
DB::transaction(function () use ($productVariantId): void {
|
||||
/** @var CartItem $item */
|
||||
$item = $this->items()
|
||||
->where('producto_variante_id', $productVariantId)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
/** @var ProductVariant $variant */
|
||||
$variant = ProductVariant::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($productVariantId);
|
||||
|
||||
$variant = $this->resolveScopedVariant($productVariantId, true);
|
||||
$variant->increment('stock', $item->cantidad);
|
||||
$item->delete();
|
||||
});
|
||||
}
|
||||
|
||||
protected function resolveScopedVariant(int $productVariantId, bool $lockForUpdate = false): ProductVariant
|
||||
{
|
||||
$query = ProductVariant::query()
|
||||
->whereKey($productVariantId)
|
||||
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $this->tenant_codigo));
|
||||
|
||||
if ($lockForUpdate) {
|
||||
$query->lockForUpdate();
|
||||
}
|
||||
|
||||
/** @var ProductVariant|null $variant */
|
||||
$variant = $query->first();
|
||||
|
||||
if ($variant === null) {
|
||||
throw new NotFoundHttpException('Product variant not found for tenant.');
|
||||
}
|
||||
|
||||
return $variant;
|
||||
}
|
||||
}
|
||||
|
||||
24
app/Domains/Cart/Requests/AddCartItemRequest.php
Normal file
24
app/Domains/Cart/Requests/AddCartItemRequest.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Cart\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AddCartItemRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'product_variant_id' => ['required', 'integer'],
|
||||
'cantidad' => ['required', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
23
app/Domains/Cart/Requests/UpdateCartItemQuantityRequest.php
Normal file
23
app/Domains/Cart/Requests/UpdateCartItemQuantityRequest.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Cart\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateCartItemQuantityRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'cantidad' => ['required', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
45
app/Domains/Cart/Resources/CartItemResource.php
Normal file
45
app/Domains/Cart/Resources/CartItemResource.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Cart\Resources;
|
||||
|
||||
use App\Domains\Catalog\Resources\ProductVariantDefinitionResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Cart\Models\CartItem
|
||||
*/
|
||||
class CartItemResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$variant = $this->variant;
|
||||
$product = $variant?->product;
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'cantidad' => $this->cantidad,
|
||||
'precio_unitario' => $this->formatMoney($variant?->precio),
|
||||
'subtotal' => $this->formatMoney(($variant?->precio ?? 0) * $this->cantidad),
|
||||
'product' => $product === null ? null : [
|
||||
'id' => $product->id,
|
||||
'nombre' => $product->nombre,
|
||||
'slug' => $product->slug,
|
||||
],
|
||||
'variant' => $variant === null ? null : [
|
||||
'id' => $variant->id,
|
||||
'nombre' => $variant->nombre,
|
||||
'slug' => $variant->slug,
|
||||
'definitions' => ProductVariantDefinitionResource::collection($variant->definitions),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function formatMoney(float|int|string|null $amount): string
|
||||
{
|
||||
return number_format((float) ($amount ?? 0), 2, '.', '');
|
||||
}
|
||||
}
|
||||
40
app/Domains/Cart/Resources/CartResource.php
Normal file
40
app/Domains/Cart/Resources/CartResource.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Cart\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Cart\Models\Cart
|
||||
*/
|
||||
class CartResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$items = $this->resource->relationLoaded('items')
|
||||
? $this->resource->getRelation('items')
|
||||
: collect();
|
||||
|
||||
$subtotal = $items->reduce(
|
||||
fn (float $carry, $item): float => $carry + ((float) ($item->variant?->precio ?? 0) * $item->cantidad),
|
||||
0.0,
|
||||
);
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'tenant_codigo' => $this->tenant_codigo,
|
||||
'status' => $this->status ?? 'active',
|
||||
'items' => CartItemResource::collection($items),
|
||||
'subtotal' => $this->formatMoney($subtotal),
|
||||
];
|
||||
}
|
||||
|
||||
protected function formatMoney(float|int|string|null $amount): string
|
||||
{
|
||||
return number_format((float) ($amount ?? 0), 2, '.', '');
|
||||
}
|
||||
}
|
||||
204
app/Domains/Cart/Services/CartService.php
Normal file
204
app/Domains/Cart/Services/CartService.php
Normal file
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Cart\Services;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class CartService
|
||||
{
|
||||
public function show(Tenant $tenant, Request $request): Cart
|
||||
{
|
||||
$identity = $this->resolveIdentity($request);
|
||||
|
||||
if ($identity === null) {
|
||||
return $this->makeEmptyCart($tenant);
|
||||
}
|
||||
|
||||
$cart = $this->findCart($tenant, $identity);
|
||||
|
||||
if ($cart === null) {
|
||||
return $this->makeEmptyCart($tenant);
|
||||
}
|
||||
|
||||
return $this->loadCart($cart);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{cart: Cart, guest_token: ?string}
|
||||
*/
|
||||
public function addItem(Tenant $tenant, Request $request, int $productVariantId, int $quantity): array
|
||||
{
|
||||
$resolvedIdentity = $this->resolveIdentity($request, true);
|
||||
$identity = $resolvedIdentity['identity'];
|
||||
$cart = $this->findOrCreateCart($tenant, $identity);
|
||||
$cart->addItem($productVariantId, $quantity);
|
||||
|
||||
return [
|
||||
'cart' => $this->loadCart($cart),
|
||||
'guest_token' => $resolvedIdentity['generated_guest_token'],
|
||||
];
|
||||
}
|
||||
|
||||
public function updateItemQuantity(Tenant $tenant, Request $request, int $productVariantId, int $quantity): Cart
|
||||
{
|
||||
$identity = $this->requireIdentity($request);
|
||||
$cart = $this->findCartOrFail($tenant, $identity);
|
||||
$cart->updateItem($productVariantId, $quantity);
|
||||
|
||||
return $this->loadCart($cart);
|
||||
}
|
||||
|
||||
public function removeItem(Tenant $tenant, Request $request, int $productVariantId): Cart
|
||||
{
|
||||
$identity = $this->requireIdentity($request);
|
||||
$cart = $this->findCartOrFail($tenant, $identity);
|
||||
$cart->removeItem($productVariantId);
|
||||
|
||||
return $this->loadCart($cart);
|
||||
}
|
||||
|
||||
public function makeGuestTokenCookie(string $guestToken): Cookie
|
||||
{
|
||||
return cookie(
|
||||
'guest_token',
|
||||
$guestToken,
|
||||
60 * 24 * 180,
|
||||
'/',
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
'lax',
|
||||
);
|
||||
}
|
||||
|
||||
protected function makeEmptyCart(Tenant $tenant): Cart
|
||||
{
|
||||
$cart = new Cart([
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$cart->setRelation('items', collect());
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
protected function loadCart(Cart $cart): Cart
|
||||
{
|
||||
return $cart->fresh()->load([
|
||||
'items.variant.product',
|
||||
'items.variant.definitions.attribute',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{identity: array{user_id: ?int, guest_token: ?string}, generated_guest_token: ?string}|null
|
||||
*/
|
||||
protected function resolveIdentity(Request $request, bool $generateGuestToken = false): ?array
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user instanceof User) {
|
||||
return [
|
||||
'identity' => [
|
||||
'user_id' => $user->getKey(),
|
||||
'guest_token' => null,
|
||||
],
|
||||
'generated_guest_token' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$guestToken = $request->cookie('guest_token');
|
||||
|
||||
if (is_string($guestToken) && $guestToken !== '') {
|
||||
return [
|
||||
'identity' => [
|
||||
'user_id' => null,
|
||||
'guest_token' => $guestToken,
|
||||
],
|
||||
'generated_guest_token' => null,
|
||||
];
|
||||
}
|
||||
|
||||
if (! $generateGuestToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$generatedGuestToken = (string) Str::uuid();
|
||||
|
||||
return [
|
||||
'identity' => [
|
||||
'user_id' => null,
|
||||
'guest_token' => $generatedGuestToken,
|
||||
],
|
||||
'generated_guest_token' => $generatedGuestToken,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{user_id: ?int, guest_token: ?string}
|
||||
*/
|
||||
protected function requireIdentity(Request $request): array
|
||||
{
|
||||
$resolvedIdentity = $this->resolveIdentity($request);
|
||||
|
||||
if ($resolvedIdentity === null) {
|
||||
throw new NotFoundHttpException('Cart not found.');
|
||||
}
|
||||
|
||||
return $resolvedIdentity['identity'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||
*/
|
||||
protected function findCart(Tenant $tenant, array $identity): ?Cart
|
||||
{
|
||||
return Cart::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->when(
|
||||
$identity['user_id'] !== null,
|
||||
fn ($query) => $query->where('user_id', $identity['user_id']),
|
||||
fn ($query) => $query->where('guest_token', $identity['guest_token']),
|
||||
)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||
*/
|
||||
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
|
||||
{
|
||||
$cart = $this->findCart($tenant, $identity);
|
||||
|
||||
if ($cart === null) {
|
||||
throw new NotFoundHttpException('Cart not found.');
|
||||
}
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||
*/
|
||||
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
|
||||
{
|
||||
$attributes = ['tenant_codigo' => $tenant->codigo];
|
||||
|
||||
if ($identity['user_id'] !== null) {
|
||||
$attributes['user_id'] = $identity['user_id'];
|
||||
} else {
|
||||
$attributes['guest_token'] = $identity['guest_token'];
|
||||
}
|
||||
|
||||
return Cart::query()->firstOrCreate($attributes, ['status' => 'active']);
|
||||
}
|
||||
|
||||
}
|
||||
11
app/Domains/Cart/routes/api.php
Normal file
11
app/Domains/Cart/routes/api.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Cart\Controllers\CartController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
||||
Route::get('cart', [CartController::class, 'show']);
|
||||
Route::post('cart/items', [CartController::class, 'addItem']);
|
||||
Route::patch('cart/items/{productVariant}', [CartController::class, 'updateItemQuantity']);
|
||||
Route::delete('cart/items/{productVariant}', [CartController::class, 'removeItem']);
|
||||
});
|
||||
68
app/Domains/Catalog/Controllers/AttributeController.php
Normal file
68
app/Domains/Catalog/Controllers/AttributeController.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Controllers;
|
||||
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Requests\StoreAttributeRequest;
|
||||
use App\Domains\Catalog\Requests\UpdateAttributeRequest;
|
||||
use App\Domains\Catalog\Resources\AttributeResource;
|
||||
use App\Domains\Catalog\Services\ProductService;
|
||||
use App\Domains\Shared\Enums\FieldType;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class AttributeController extends Controller
|
||||
{
|
||||
public function index(Tenant $tenant): JsonResponse
|
||||
{
|
||||
$query = Attribute::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->with('options')
|
||||
->latest();
|
||||
|
||||
return AttributeResource::collection($query->paginateFromRequest())->response();
|
||||
}
|
||||
|
||||
public function store(StoreAttributeRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
$attribute = ProductService::createAttribute($tenant, $request->validated());
|
||||
|
||||
return AttributeResource::make($attribute)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Tenant $tenant, Attribute $attribute): AttributeResource
|
||||
{
|
||||
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
|
||||
|
||||
return AttributeResource::make($attribute->load('options'));
|
||||
}
|
||||
|
||||
public function update(UpdateAttributeRequest $request, Tenant $tenant, Attribute $attribute): AttributeResource
|
||||
{
|
||||
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
|
||||
$attribute = ProductService::updateAttribute($attribute, $request->validated());
|
||||
|
||||
return AttributeResource::make($attribute);
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant, Attribute $attribute): Response
|
||||
{
|
||||
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
|
||||
ProductService::deleteAttribute($attribute);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
protected function resolveScopedAttribute(Tenant $tenant, Attribute $attribute): Attribute
|
||||
{
|
||||
if ($attribute->tenant_codigo !== $tenant->codigo) {
|
||||
throw new NotFoundHttpException('Attribute not found for tenant.');
|
||||
}
|
||||
|
||||
return $attribute;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ class BrandController extends Controller
|
||||
public function index(Tenant $tenant): JsonResponse
|
||||
{
|
||||
return BrandResource::collection(
|
||||
Brand::query()->where('tenant_codigo', $tenant->codigo)->orderByDesc('id')->get()
|
||||
Brand::query()->where('tenant_codigo', $tenant->codigo)->orderByDesc('id')->paginateFromRequest()
|
||||
)->response();
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ class CategoryController extends Controller
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->with(['parent', 'subCategories', 'tenant'])
|
||||
->orderByDesc('id')
|
||||
->get()
|
||||
->paginateFromRequest()
|
||||
)->response();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Controllers;
|
||||
|
||||
use App\Domains\Catalog\Models\ProductAttribute;
|
||||
use App\Domains\Catalog\Requests\StoreProductAttributeRequest;
|
||||
use App\Domains\Catalog\Requests\UpdateProductAttributeRequest;
|
||||
use App\Domains\Catalog\Resources\ProductAttributeResource;
|
||||
use App\Domains\Catalog\Support\ProductAttributeType;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class ProductAttributeController extends Controller
|
||||
{
|
||||
public function index(Tenant $tenant): JsonResponse
|
||||
{
|
||||
$query = ProductAttribute::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->with('options')
|
||||
->latest();
|
||||
|
||||
return ProductAttributeResource::collection($query->get())->response();
|
||||
}
|
||||
|
||||
public function store(StoreProductAttributeRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
$attribute = DB::transaction(function () use ($request, $tenant): ProductAttribute {
|
||||
$validated = $request->validated();
|
||||
$options = $validated['options'] ?? [];
|
||||
unset($validated['options']);
|
||||
|
||||
$type = ProductAttributeType::from((string) $validated['type']);
|
||||
if (! $type->supportsOptions()) {
|
||||
$validated['metadata_schema'] = null;
|
||||
$options = [];
|
||||
}
|
||||
|
||||
/** @var ProductAttribute $attribute */
|
||||
$attribute = ProductAttribute::query()->create([
|
||||
...$validated,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
$attribute->options()->createMany($options);
|
||||
|
||||
return $attribute->load('options');
|
||||
});
|
||||
|
||||
return ProductAttributeResource::make($attribute)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Tenant $tenant, ProductAttribute $productAttribute): ProductAttributeResource
|
||||
{
|
||||
$productAttribute = $this->resolveScopedAttribute($tenant, $productAttribute);
|
||||
|
||||
return ProductAttributeResource::make($productAttribute->load('options'));
|
||||
}
|
||||
|
||||
public function update(UpdateProductAttributeRequest $request, Tenant $tenant, ProductAttribute $productAttribute): ProductAttributeResource
|
||||
{
|
||||
$productAttribute = $this->resolveScopedAttribute($tenant, $productAttribute);
|
||||
|
||||
$productAttribute = DB::transaction(function () use ($request, $productAttribute): ProductAttribute {
|
||||
$validated = $request->validated();
|
||||
$options = $validated['options'] ?? [];
|
||||
unset($validated['options']);
|
||||
|
||||
$type = ProductAttributeType::from((string) $validated['type']);
|
||||
if (! $type->supportsOptions()) {
|
||||
$validated['metadata_schema'] = null;
|
||||
$options = [];
|
||||
}
|
||||
|
||||
$productAttribute->update($validated);
|
||||
$productAttribute->options()->delete();
|
||||
$productAttribute->options()->createMany($options);
|
||||
|
||||
return $productAttribute->load('options');
|
||||
});
|
||||
|
||||
return ProductAttributeResource::make($productAttribute);
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant, ProductAttribute $productAttribute): Response
|
||||
{
|
||||
$productAttribute = $this->resolveScopedAttribute($tenant, $productAttribute);
|
||||
$productAttribute->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
protected function resolveScopedAttribute(Tenant $tenant, ProductAttribute $attribute): ProductAttribute
|
||||
{
|
||||
if ($attribute->tenant_codigo !== $tenant->codigo) {
|
||||
throw new NotFoundHttpException('Product attribute not found for tenant.');
|
||||
}
|
||||
|
||||
return $attribute;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Domains\Catalog\Models\Product;
|
||||
use App\Domains\Catalog\Requests\StoreProductRequest;
|
||||
use App\Domains\Catalog\Requests\UpdateProductRequest;
|
||||
use App\Domains\Catalog\Resources\ProductResource;
|
||||
use App\Domains\Catalog\Services\ProductService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -17,16 +18,17 @@ class ProductController extends Controller
|
||||
public function index(Tenant $tenant): JsonResponse
|
||||
{
|
||||
return ProductResource::collection(
|
||||
Product::query()->where('tenant_codigo', $tenant->codigo)->latest()->get()
|
||||
Product::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->with(['attachments', 'brand', 'category'])
|
||||
->latest()
|
||||
->paginateFromRequest()
|
||||
)->response();
|
||||
}
|
||||
|
||||
public function store(StoreProductRequest $request, Tenant $tenant): JsonResponse
|
||||
public function store(StoreProductRequest $request, Tenant $tenant, ProductService $productService): JsonResponse
|
||||
{
|
||||
$product = Product::query()->create([
|
||||
...$request->validated(),
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
$product = $productService->create($tenant, $request->validated());
|
||||
|
||||
return ProductResource::make($product)->response()->setStatusCode(201);
|
||||
}
|
||||
@@ -35,21 +37,27 @@ class ProductController extends Controller
|
||||
{
|
||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
||||
|
||||
return ProductResource::make($producto);
|
||||
return ProductResource::make($producto->load([
|
||||
'attachments',
|
||||
'brand',
|
||||
'category',
|
||||
'variants.definitions.attribute',
|
||||
'variants.attachments',
|
||||
]));
|
||||
}
|
||||
|
||||
public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto): ProductResource
|
||||
public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource
|
||||
{
|
||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
||||
$producto->update($request->validated());
|
||||
$producto = $productService->update($producto, $request->validated());
|
||||
|
||||
return ProductResource::make($producto);
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant, Product $producto): Response
|
||||
public function destroy(Tenant $tenant, Product $producto, ProductService $productService): Response
|
||||
{
|
||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
||||
$producto->delete();
|
||||
$productService->delete($producto);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Domains\Catalog\Models\ProductVariant;
|
||||
use App\Domains\Catalog\Requests\StoreProductVariantRequest;
|
||||
use App\Domains\Catalog\Requests\UpdateProductVariantRequest;
|
||||
use App\Domains\Catalog\Resources\ProductVariantResource;
|
||||
use App\Domains\Catalog\Services\ProductService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -20,28 +21,18 @@ class ProductVariantController extends Controller
|
||||
{
|
||||
$query = ProductVariant::query()
|
||||
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $tenant->codigo))
|
||||
->with(['product', 'definitions.attribute'])
|
||||
->with(['product', 'definitions.attribute', 'attachments'])
|
||||
->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, ProductService $productService): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$variant = DB::transaction(function () use ($request, $tenant): ProductVariant {
|
||||
|
||||
$definitions = $validated['definitions'] ?? [];
|
||||
unset($validated['definitions']);
|
||||
$product = $this->resolveTenantProduct($tenant, (int) $validated['producto_id']);
|
||||
|
||||
$product = $this->resolveTenantProduct($tenant, (int) $validated['producto_id']);
|
||||
|
||||
/** @var ProductVariant $variant */
|
||||
$variant = $product->variants()->create($validated);
|
||||
$variant->definitions()->createMany($definitions);
|
||||
|
||||
return $variant->load(['product', 'definitions.attribute']);
|
||||
});
|
||||
$variant = $productService->createVariant($product, $validated);
|
||||
|
||||
return ProductVariantResource::make($variant)->response()->setStatusCode(201);
|
||||
}
|
||||
@@ -50,34 +41,25 @@ class ProductVariantController extends Controller
|
||||
{
|
||||
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
|
||||
|
||||
return ProductVariantResource::make($productVariant->load(['product', 'definitions.attribute']));
|
||||
return ProductVariantResource::make($productVariant->load(['product', 'definitions.attribute', 'attachments']));
|
||||
}
|
||||
|
||||
public function update(UpdateProductVariantRequest $request, Tenant $tenant, ProductVariant $productVariant): ProductVariantResource
|
||||
public function update(UpdateProductVariantRequest $request, Tenant $tenant, ProductVariant $productVariant, ProductService $productService): ProductVariantResource
|
||||
{
|
||||
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
|
||||
$validated = $request->validated();
|
||||
|
||||
$productVariant = DB::transaction(function () use ($request, $tenant, $productVariant): ProductVariant {
|
||||
$validated = $request->validated();
|
||||
$definitions = $validated['definitions'] ?? [];
|
||||
unset($validated['definitions']);
|
||||
$this->resolveTenantProduct($tenant, (int) $validated['producto_id']);
|
||||
|
||||
$this->resolveTenantProduct($tenant, (int) $validated['producto_id']);
|
||||
|
||||
$productVariant->update($validated);
|
||||
$productVariant->definitions()->delete();
|
||||
$productVariant->definitions()->createMany($definitions);
|
||||
|
||||
return $productVariant->load(['product', 'definitions.attribute']);
|
||||
});
|
||||
$productVariant = $productService->updateVariant($productVariant, $validated);
|
||||
|
||||
return ProductVariantResource::make($productVariant);
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant, ProductVariant $productVariant): Response
|
||||
public function destroy(Tenant $tenant, ProductVariant $productVariant, ProductService $productService): Response
|
||||
{
|
||||
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
|
||||
$productVariant->delete();
|
||||
$productService->deleteVariant($productVariant);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Catalog\Support\ProductAttributeType;
|
||||
use App\Domains\Shared\Enums\FieldType;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -18,11 +18,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
'metadata_schema',
|
||||
'type',
|
||||
])]
|
||||
class ProductAttribute extends Model
|
||||
class Attribute extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'productos_attributes';
|
||||
protected $table = 'attribute';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
@@ -32,7 +32,7 @@ class ProductAttribute extends Model
|
||||
return [
|
||||
'is_required' => 'boolean',
|
||||
'metadata_schema' => 'array',
|
||||
'type' => ProductAttributeType::class,
|
||||
'type' => FieldType::class,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -45,11 +45,11 @@ class ProductAttribute extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProductAttributeOption, $this>
|
||||
* @return HasMany<AttributeOption, $this>
|
||||
*/
|
||||
public function options(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductAttributeOption::class, 'attribute_id')->orderBy('sort_order');
|
||||
return $this->hasMany(AttributeOption::class, 'attribute_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -9,11 +9,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'attribute_id',
|
||||
'value',
|
||||
'label',
|
||||
'sort_order',
|
||||
'metadata',
|
||||
])]
|
||||
class ProductAttributeOption extends Model
|
||||
class AttributeOption extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
@@ -31,10 +32,10 @@ class ProductAttributeOption extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<ProductAttribute, $this>
|
||||
* @return BelongsTo<Attribute, $this>
|
||||
*/
|
||||
public function attribute(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductAttribute::class, 'attribute_id');
|
||||
return $this->belongsTo(Attribute::class, 'attribute_id');
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,21 @@
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Models\Brand;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_codigo',
|
||||
'categoria_id',
|
||||
'brand_id',
|
||||
'slug',
|
||||
'nombre',
|
||||
'descripcion',
|
||||
@@ -27,6 +32,7 @@ class Product extends Model
|
||||
{
|
||||
return [
|
||||
'categoria_id' => 'integer',
|
||||
'brand_id' => 'integer',
|
||||
'precio' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
@@ -39,6 +45,22 @@ class Product extends Model
|
||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Category, $this>
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'categoria_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Brand, $this>
|
||||
*/
|
||||
public function brand(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Brand::class, 'brand_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProductVariant, $this>
|
||||
*/
|
||||
@@ -46,4 +68,210 @@ class Product extends Model
|
||||
{
|
||||
return $this->hasMany(ProductVariant::class, 'producto_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Attribute, $this>
|
||||
*/
|
||||
public function attributes(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Attribute::class,
|
||||
'products_attributes',
|
||||
'product_id',
|
||||
'attribute_id'
|
||||
)->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Attachment, $this>
|
||||
*/
|
||||
public function attachments(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Attachment::class,
|
||||
'productos_attachments',
|
||||
'producto_id',
|
||||
'attachment_id'
|
||||
)->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a variant for this product with its definitions.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function createVariant(array $data): ProductVariant
|
||||
{
|
||||
$definitions = $data['definitions'] ?? [];
|
||||
$this->validateVariantDefinitions($definitions);
|
||||
|
||||
unset($data['definitions']);
|
||||
|
||||
/** @var ProductVariant $variant */
|
||||
$variant = $this->variants()->create($data);
|
||||
$variant->definitions()->createMany($definitions);
|
||||
|
||||
return $variant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple variants for this product.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $variantsData
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, ProductVariant>
|
||||
*/
|
||||
public function createVariants(array $variantsData): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
$variants = new \Illuminate\Database\Eloquent\Collection();
|
||||
|
||||
foreach ($variantsData as $variantData) {
|
||||
$variants->push($this->createVariant($variantData));
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a product variant.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function updateVariant(ProductVariant $variant, array $data): ProductVariant
|
||||
{
|
||||
$definitions = $data['definitions'] ?? [];
|
||||
$this->validateVariantDefinitions($definitions);
|
||||
|
||||
unset($data['definitions']);
|
||||
unset($data['producto_id']);
|
||||
|
||||
$variant->update($data);
|
||||
$variant->definitions()->delete();
|
||||
$variant->definitions()->createMany($definitions);
|
||||
|
||||
return $variant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a product variant.
|
||||
*/
|
||||
public function deleteVariant(ProductVariant $variant): void
|
||||
{
|
||||
$variant->definitions()->delete();
|
||||
$variant->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate variant definitions options against Attribute configuration.
|
||||
*
|
||||
* @param array $definitions
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
protected function validateVariantDefinitions(array $definitions): void
|
||||
{
|
||||
foreach ($definitions as $definition) {
|
||||
$attributeId = $definition['attribute_id'] ?? null;
|
||||
if (!$attributeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$attribute = Attribute::find($attributeId);
|
||||
if (!$attribute) {
|
||||
throw new \InvalidArgumentException("Attribute with ID {$attributeId} not found.");
|
||||
}
|
||||
|
||||
if ($attribute->type === \App\Domains\Shared\Enums\FieldType::Select) {
|
||||
$allowedValues = $attribute->options()->pluck('value')->toArray();
|
||||
$val = $definition['value'] ?? null;
|
||||
if ($val !== null && !in_array($val, $allowedValues, true)) {
|
||||
throw new \InvalidArgumentException("The value '{$val}' is not a valid option for the select attribute '{$attribute->nombre}'.");
|
||||
}
|
||||
} elseif ($attribute->type === \App\Domains\Shared\Enums\FieldType::Multiselect) {
|
||||
$allowedValues = $attribute->options()->pluck('value')->toArray();
|
||||
$val = $definition['value'] ?? null;
|
||||
if ($val !== null) {
|
||||
$values = [];
|
||||
if (is_array($val)) {
|
||||
$values = $val;
|
||||
} else {
|
||||
$decoded = json_decode($val, true);
|
||||
if (is_array($decoded)) {
|
||||
$values = $decoded;
|
||||
} else {
|
||||
$values = array_map('trim', explode(',', $val));
|
||||
}
|
||||
}
|
||||
foreach ($values as $v) {
|
||||
if (!in_array($v, $allowedValues, true)) {
|
||||
throw new \InvalidArgumentException("The value '{$v}' is not a valid option for the multiselect attribute '{$attribute->nombre}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an attribute.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function createAttribute(Tenant $tenant, array $data): Attribute
|
||||
{
|
||||
$options = $data['options'] ?? [];
|
||||
unset($data['options']);
|
||||
|
||||
$type = \App\Domains\Shared\Enums\FieldType::from((string) $data['type']);
|
||||
if (! $type->supportsOptions() && ! empty($options)) {
|
||||
throw new \InvalidArgumentException('Options are only allowed for select and multiselect attributes.');
|
||||
}
|
||||
|
||||
if (! $type->supportsOptions()) {
|
||||
$data['metadata_schema'] = null;
|
||||
}
|
||||
|
||||
/** @var Attribute $attribute */
|
||||
$attribute = Attribute::query()->create([
|
||||
...$data,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
$attribute->options()->createMany($options);
|
||||
|
||||
return $attribute->load('options');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an attribute.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function updateAttribute(Attribute $attribute, array $data): Attribute
|
||||
{
|
||||
$options = $data['options'] ?? [];
|
||||
unset($data['options']);
|
||||
|
||||
$typeStr = $data['type'] ?? $attribute->type->value;
|
||||
$type = \App\Domains\Shared\Enums\FieldType::from((string) $typeStr);
|
||||
if (! $type->supportsOptions() && ! empty($options)) {
|
||||
throw new \InvalidArgumentException('Options are only allowed for select and multiselect attributes.');
|
||||
}
|
||||
|
||||
if (! $type->supportsOptions()) {
|
||||
$data['metadata_schema'] = null;
|
||||
}
|
||||
|
||||
$attribute->update($data);
|
||||
$attribute->options()->delete();
|
||||
$attribute->options()->createMany($options);
|
||||
|
||||
return $attribute->load('options');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an attribute.
|
||||
*/
|
||||
public static function deleteAttribute(Attribute $attribute): void
|
||||
{
|
||||
$attribute->options()->delete();
|
||||
$attribute->delete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
@@ -46,4 +48,17 @@ class ProductVariant extends Model
|
||||
{
|
||||
return $this->hasMany(ProductVariantDefinition::class, 'producto_variante_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Attachment, $this>
|
||||
*/
|
||||
public function attachments(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Attachment::class,
|
||||
'variantes_attachments',
|
||||
'variante_id',
|
||||
'attachment_id'
|
||||
)->withTimestamps();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ class ProductVariantDefinition extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'productos_variantes_definiciones';
|
||||
protected $table = 'productos_variantes_values';
|
||||
|
||||
/**
|
||||
* @return BelongsTo<ProductVariant, $this>
|
||||
@@ -27,10 +27,10 @@ class ProductVariantDefinition extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<ProductAttribute, $this>
|
||||
* @return BelongsTo<Attribute, $this>
|
||||
*/
|
||||
public function attribute(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductAttribute::class, 'attribute_id');
|
||||
return $this->belongsTo(Attribute::class, 'attribute_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Catalog\Support\ProductAttributeType;
|
||||
use App\Domains\Shared\Enums\FieldType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class StoreProductAttributeRequest extends FormRequest
|
||||
class StoreAttributeRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -24,25 +24,26 @@ class StoreProductAttributeRequest extends FormRequest
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('productos_attributes', 'codigo')->where(
|
||||
Rule::unique('attribute', 'codigo')->where(
|
||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||
),
|
||||
],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'is_required' => ['sometimes', 'boolean'],
|
||||
'metadata_schema' => ['nullable', 'array'],
|
||||
'type' => ['required', Rule::enum(ProductAttributeType::class)],
|
||||
'type' => ['required', Rule::enum(FieldType::class)],
|
||||
'options' => [
|
||||
Rule::requiredIf(fn (): bool => in_array($this->input('type'), [
|
||||
ProductAttributeType::Select->value,
|
||||
ProductAttributeType::Multiselect->value,
|
||||
FieldType::Select->value,
|
||||
FieldType::Multiselect->value,
|
||||
], true)),
|
||||
Rule::prohibitedIf(fn (): bool => ! in_array($this->input('type'), [
|
||||
ProductAttributeType::Select->value,
|
||||
ProductAttributeType::Multiselect->value,
|
||||
FieldType::Select->value,
|
||||
FieldType::Multiselect->value,
|
||||
], true)),
|
||||
'array',
|
||||
],
|
||||
'options.*.value' => ['required', 'string', 'max:255'],
|
||||
'options.*.label' => ['required', 'string', 'max:255'],
|
||||
'options.*.sort_order' => ['sometimes', 'integer'],
|
||||
'options.*.metadata' => ['nullable', 'array'],
|
||||
@@ -53,7 +54,7 @@ class StoreProductAttributeRequest extends FormRequest
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
$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')) {
|
||||
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -19,10 +20,27 @@ class StoreProductRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'categoria_id' => ['required', 'integer'],
|
||||
'brand_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('brands', 'id')->where(
|
||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||
),
|
||||
],
|
||||
'slug' => ['required', 'string', 'max:255', Rule::unique('productos', 'slug')],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'descripcion' => ['nullable', 'string'],
|
||||
'precio' => ['required', 'numeric', 'min:0'],
|
||||
'attribute_ids' => ['sometimes', 'array'],
|
||||
'attribute_ids.*' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('attribute', 'id')->where(
|
||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||
),
|
||||
],
|
||||
'images' => ['sometimes', 'nullable', 'array'],
|
||||
'images.*' => ['required', new ImageOrBase64Rule()],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -42,11 +43,25 @@ class StoreProductVariantRequest extends FormRequest
|
||||
'required',
|
||||
'integer',
|
||||
'distinct',
|
||||
Rule::exists('productos_attributes', 'id')->where(
|
||||
Rule::exists('attribute', 'id')->where(
|
||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||
),
|
||||
function ($attribute, $value, $fail) {
|
||||
$productId = $this->input('producto_id');
|
||||
if ($productId) {
|
||||
$exists = \Illuminate\Support\Facades\DB::table('products_attributes')
|
||||
->where('product_id', $productId)
|
||||
->where('attribute_id', $value)
|
||||
->exists();
|
||||
if (!$exists) {
|
||||
$fail('The selected attribute is not associated with the product.');
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
'definitions.*.value' => ['nullable', 'string'],
|
||||
'images' => ['sometimes', 'nullable', 'array'],
|
||||
'images.*' => ['required', new ImageOrBase64Rule()],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Catalog\Models\ProductAttribute;
|
||||
use App\Domains\Catalog\Support\ProductAttributeType;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Shared\Enums\FieldType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class UpdateProductAttributeRequest extends FormRequest
|
||||
class UpdateAttributeRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -20,23 +20,24 @@ class UpdateProductAttributeRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var ProductAttribute|null $attribute */
|
||||
$attribute = $this->route('productAttribute');
|
||||
/** @var Attribute|null $attribute */
|
||||
$attribute = $this->route('attribute');
|
||||
|
||||
return [
|
||||
'codigo' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('productos_attributes', 'codigo')
|
||||
Rule::unique('attribute', 'codigo')
|
||||
->ignore($attribute?->id)
|
||||
->where(fn ($query) => $query->where('tenant_codigo', $attribute?->tenant_codigo)),
|
||||
],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'is_required' => ['sometimes', 'boolean'],
|
||||
'metadata_schema' => ['nullable', 'array'],
|
||||
'type' => ['required', Rule::enum(ProductAttributeType::class)],
|
||||
'type' => ['required', Rule::enum(FieldType::class)],
|
||||
'options' => ['sometimes', 'array'],
|
||||
'options.*.value' => ['required', 'string', 'max:255'],
|
||||
'options.*.label' => ['required', 'string', 'max:255'],
|
||||
'options.*.sort_order' => ['sometimes', 'integer'],
|
||||
'options.*.metadata' => ['nullable', 'array'],
|
||||
@@ -47,7 +48,7 @@ class UpdateProductAttributeRequest extends FormRequest
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
$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')) {
|
||||
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Catalog\Models\Product;
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -23,6 +24,13 @@ class UpdateProductRequest extends FormRequest
|
||||
|
||||
return [
|
||||
'categoria_id' => ['required', 'integer'],
|
||||
'brand_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('brands', 'id')->where(
|
||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||
),
|
||||
],
|
||||
'slug' => [
|
||||
'required',
|
||||
'string',
|
||||
@@ -32,6 +40,16 @@ class UpdateProductRequest extends FormRequest
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'descripcion' => ['nullable', 'string'],
|
||||
'precio' => ['required', 'numeric', 'min:0'],
|
||||
'attribute_ids' => ['sometimes', 'array'],
|
||||
'attribute_ids.*' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('attribute', 'id')->where(
|
||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||
),
|
||||
],
|
||||
'images' => ['sometimes', 'nullable', 'array'],
|
||||
'images.*' => ['required', new ImageOrBase64Rule()],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -46,11 +47,25 @@ class UpdateProductVariantRequest extends FormRequest
|
||||
'required',
|
||||
'integer',
|
||||
'distinct',
|
||||
Rule::exists('productos_attributes', 'id')->where(
|
||||
Rule::exists('attribute', 'id')->where(
|
||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
||||
),
|
||||
function ($attribute, $value, $fail) {
|
||||
$productId = $this->input('producto_id');
|
||||
if ($productId) {
|
||||
$exists = \Illuminate\Support\Facades\DB::table('products_attributes')
|
||||
->where('product_id', $productId)
|
||||
->where('attribute_id', $value)
|
||||
->exists();
|
||||
if (!$exists) {
|
||||
$fail('The selected attribute is not associated with the product.');
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
'definitions.*.value' => ['nullable', 'string'],
|
||||
'images' => ['sometimes', 'nullable', 'array'],
|
||||
'images.*' => ['required', new ImageOrBase64Rule()],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Catalog\Models\ProductAttributeOption
|
||||
* @mixin \App\Domains\Catalog\Models\AttributeOption
|
||||
*/
|
||||
class ProductAttributeOptionResource extends JsonResource
|
||||
class AttributeOptionResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
@@ -17,6 +17,7 @@ class ProductAttributeOptionResource extends JsonResource
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'value' => $this->value,
|
||||
'label' => $this->label,
|
||||
'sort_order' => $this->sort_order,
|
||||
'metadata' => $this->metadata,
|
||||
@@ -6,9 +6,9 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Catalog\Models\ProductAttribute
|
||||
* @mixin \App\Domains\Catalog\Models\Attribute
|
||||
*/
|
||||
class ProductAttributeResource extends JsonResource
|
||||
class AttributeResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
@@ -23,7 +23,7 @@ class ProductAttributeResource extends JsonResource
|
||||
'is_required' => $this->is_required,
|
||||
'metadata_schema' => $this->metadata_schema,
|
||||
'type' => $this->type?->value ?? $this->type,
|
||||
'options' => ProductAttributeOptionResource::collection($this->whenLoaded('options')),
|
||||
'options' => AttributeOptionResource::collection($this->whenLoaded('options')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Domains\Catalog\Resources;
|
||||
|
||||
use App\Domains\Catalog\Resources\BrandResource;
|
||||
use App\Domains\Catalog\Resources\CategoryResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -19,10 +21,17 @@ class ProductResource extends JsonResource
|
||||
'id' => $this->id,
|
||||
'tenant_codigo' => $this->tenant_codigo,
|
||||
'categoria_id' => $this->categoria_id,
|
||||
'brand_id' => $this->brand_id,
|
||||
'slug' => $this->slug,
|
||||
'nombre' => $this->nombre,
|
||||
'descripcion' => $this->descripcion,
|
||||
'precio' => $this->precio,
|
||||
'category' => CategoryResource::make($this->whenLoaded('category')),
|
||||
'brand' => BrandResource::make($this->whenLoaded('brand')),
|
||||
'images' => $this->whenLoaded('attachments', fn () =>
|
||||
$this->attachments->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))->values()
|
||||
),
|
||||
'variants' => ProductVariantResource::collection($this->whenLoaded('variants')),
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
|
||||
@@ -20,7 +20,7 @@ class ProductVariantDefinitionResource extends JsonResource
|
||||
'producto_variante_id' => $this->producto_variante_id,
|
||||
'attribute_id' => $this->attribute_id,
|
||||
'value' => $this->value,
|
||||
'attribute' => ProductAttributeResource::make($this->whenLoaded('attribute')),
|
||||
'attribute' => AttributeResource::make($this->whenLoaded('attribute')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,21 @@ class ProductVariantResource extends JsonResource
|
||||
'precio' => $this->precio,
|
||||
'product' => ProductResource::make($this->whenLoaded('product')),
|
||||
'definitions' => ProductVariantDefinitionResource::collection($this->whenLoaded('definitions')),
|
||||
'images' => $this->whenLoaded('attachments', function () {
|
||||
if ($this->attachments->isNotEmpty()) {
|
||||
return $this->attachments
|
||||
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
||||
->values();
|
||||
}
|
||||
|
||||
// Fallback: use product-level attachments when the variant has none
|
||||
$this->loadMissing('product.attachments');
|
||||
|
||||
return $this->product?->attachments
|
||||
?->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
||||
?->values()
|
||||
?? collect();
|
||||
}),
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
|
||||
@@ -2,7 +2,246 @@
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Attachable\Services\AttachmentService;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\Product;
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProductService
|
||||
{
|
||||
// Reserved for product domain business logic.
|
||||
public function __construct(protected AttachmentService $attachmentService) {}
|
||||
/**
|
||||
* Create a product.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function create(Tenant $tenant, array $data): Product
|
||||
{
|
||||
return DB::transaction(function () use ($tenant, $data) {
|
||||
$attributeIds = $data['attribute_ids'] ?? [];
|
||||
$images = $data['images'] ?? [];
|
||||
unset($data['attribute_ids'], $data['images']);
|
||||
|
||||
/** @var Product $product */
|
||||
$product = Product::query()->create([
|
||||
...$data,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
|
||||
$product->attributes()->sync($attributeIds);
|
||||
|
||||
if (! empty($images)) {
|
||||
$this->syncProductImages($product, $images);
|
||||
}
|
||||
|
||||
return $product->load(['attributes', 'attachments', 'brand', 'category']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a product.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function update(Product $product, array $data): Product
|
||||
{
|
||||
return DB::transaction(function () use ($product, $data) {
|
||||
$hasAttributeIds = array_key_exists('attribute_ids', $data);
|
||||
$attributeIds = $data['attribute_ids'] ?? [];
|
||||
$hasImages = array_key_exists('images', $data);
|
||||
$images = $data['images'] ?? [];
|
||||
unset($data['attribute_ids'], $data['images']);
|
||||
|
||||
// Ensure tenant_codigo cannot be updated/changed
|
||||
unset($data['tenant_codigo']);
|
||||
|
||||
$product->update($data);
|
||||
|
||||
if ($hasAttributeIds) {
|
||||
$product->attributes()->sync($attributeIds);
|
||||
}
|
||||
|
||||
if ($hasImages) {
|
||||
$this->syncProductImages($product, $images);
|
||||
}
|
||||
|
||||
return $product->load(['attributes', 'attachments', 'brand', 'category']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a product.
|
||||
*/
|
||||
public function delete(Product $product): void
|
||||
{
|
||||
DB::transaction(function () use ($product) {
|
||||
foreach ($product->variants as $variant) {
|
||||
$this->deleteVariantAttachments($variant);
|
||||
$product->deleteVariant($variant);
|
||||
}
|
||||
|
||||
// Delete product-level attachments from S3 and database
|
||||
$existing = $product->attachments()->get();
|
||||
$product->attachments()->detach();
|
||||
foreach ($existing as $attachment) {
|
||||
$this->attachmentService->delete($attachment);
|
||||
}
|
||||
|
||||
$product->attributes()->detach();
|
||||
$product->delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a product variant.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function createVariant(Product $product, array $data): ProductVariant
|
||||
{
|
||||
return DB::transaction(function () use ($product, $data) {
|
||||
$images = $data['images'] ?? [];
|
||||
unset($data['images']);
|
||||
|
||||
$variant = $product->createVariant($data);
|
||||
|
||||
if (! empty($images)) {
|
||||
$this->syncVariantImages($variant, $images);
|
||||
}
|
||||
|
||||
return $variant->load(['product', 'definitions.attribute', 'attachments']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a product variant.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function updateVariant(ProductVariant $variant, array $data): ProductVariant
|
||||
{
|
||||
return DB::transaction(function () use ($variant, $data) {
|
||||
$hasImages = array_key_exists('images', $data);
|
||||
$images = $data['images'] ?? [];
|
||||
unset($data['images']);
|
||||
|
||||
/** @var Product $product */
|
||||
$product = $variant->product;
|
||||
$updatedVariant = $product->updateVariant($variant, $data);
|
||||
|
||||
if ($hasImages) {
|
||||
$this->syncVariantImages($updatedVariant, $images);
|
||||
}
|
||||
|
||||
return $updatedVariant->load(['product', 'definitions.attribute', 'attachments']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a product variant.
|
||||
*/
|
||||
public function deleteVariant(ProductVariant $variant): void
|
||||
{
|
||||
DB::transaction(function () use ($variant) {
|
||||
/** @var Product $product */
|
||||
$product = $variant->product;
|
||||
$this->deleteVariantAttachments($variant);
|
||||
$product->deleteVariant($variant);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an attribute.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function createAttribute(Tenant $tenant, array $data): Attribute
|
||||
{
|
||||
return DB::transaction(function () use ($tenant, $data) {
|
||||
return Product::createAttribute($tenant, $data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an attribute.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function updateAttribute(Attribute $attribute, array $data): Attribute
|
||||
{
|
||||
return DB::transaction(function () use ($attribute, $data) {
|
||||
return Product::updateAttribute($attribute, $data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a list of image files/base64 strings and sync them to a variant.
|
||||
*
|
||||
* When called on update, the existing attachments are detached first so the
|
||||
* final set always matches exactly what was sent in the request.
|
||||
*
|
||||
* @param array<int, UploadedFile|string> $images
|
||||
*/
|
||||
protected function syncVariantImages(ProductVariant $variant, array $images): void
|
||||
{
|
||||
$this->deleteVariantAttachments($variant);
|
||||
|
||||
$attachmentIds = [];
|
||||
|
||||
foreach ($images as $image) {
|
||||
$attachment = $this->attachmentService->store($image, 'variants');
|
||||
$attachmentIds[] = $attachment->id;
|
||||
}
|
||||
|
||||
$variant->attachments()->sync($attachmentIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a list of image files/base64 strings and sync them to a product.
|
||||
*
|
||||
* Same logic as syncVariantImages but for products without variants.
|
||||
*
|
||||
* @param array<int, UploadedFile|string> $images
|
||||
*/
|
||||
protected function syncProductImages(Product $product, array $images): void
|
||||
{
|
||||
// Detach pivot record and delete attachment from S3 and database
|
||||
$existing = $product->attachments()->get();
|
||||
$product->attachments()->detach();
|
||||
foreach ($existing as $attachment) {
|
||||
$this->attachmentService->delete($attachment);
|
||||
}
|
||||
|
||||
$attachmentIds = [];
|
||||
|
||||
foreach ($images as $image) {
|
||||
$attachment = $this->attachmentService->store($image, 'products');
|
||||
$attachmentIds[] = $attachment->id;
|
||||
}
|
||||
|
||||
$product->attachments()->sync($attachmentIds);
|
||||
}
|
||||
|
||||
protected function deleteVariantAttachments(ProductVariant $variant): void
|
||||
{
|
||||
$existing = $variant->attachments()->get();
|
||||
$variant->attachments()->detach();
|
||||
|
||||
foreach ($existing as $attachment) {
|
||||
$this->attachmentService->delete($attachment);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an attribute.
|
||||
*/
|
||||
public static function deleteAttribute(Attribute $attribute): void
|
||||
{
|
||||
DB::transaction(function () use ($attribute) {
|
||||
Product::deleteAttribute($attribute);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
use App\Domains\Catalog\Controllers\BrandController;
|
||||
use App\Domains\Catalog\Controllers\CategoryController;
|
||||
use App\Domains\Catalog\Controllers\ProductController;
|
||||
use App\Domains\Catalog\Controllers\ProductAttributeController;
|
||||
use App\Domains\Catalog\Controllers\AttributeController;
|
||||
use App\Domains\Catalog\Controllers\ProductVariantController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@@ -11,8 +11,7 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
||||
Route::apiResource('marcas', BrandController::class)->parameters(['marcas' => 'marca']);
|
||||
Route::apiResource('categorias', CategoryController::class)->parameters(['categorias' => 'categoria']);
|
||||
Route::apiResource('productos', ProductController::class);
|
||||
Route::apiResource('product-attributes', ProductAttributeController::class)
|
||||
->parameters(['product-attributes' => 'productAttribute']);
|
||||
Route::apiResource('attributes', AttributeController::class);
|
||||
Route::apiResource('product-variants', ProductVariantController::class)
|
||||
->parameters(['product-variants' => 'productVariant']);
|
||||
});
|
||||
|
||||
129
app/Domains/Purchase/Controllers/PurchaseController.php
Normal file
129
app/Domains/Purchase/Controllers/PurchaseController.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Purchase\Controllers;
|
||||
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Requests\StorePurchaseRequest;
|
||||
use App\Domains\Purchase\Resources\PurchaseResource;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class PurchaseController extends Controller
|
||||
{
|
||||
public function index(Request $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
return PurchaseResource::collection(
|
||||
Purchase::query()
|
||||
->with(['items.variant.product', 'items.variant.definitions'])
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->where('user_id', $request->user()->id)
|
||||
->latest()
|
||||
->paginateFromRequest()
|
||||
)->response();
|
||||
}
|
||||
|
||||
public function store(StorePurchaseRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$items = $data['items'];
|
||||
|
||||
unset($data['items']);
|
||||
|
||||
$variants = $this->resolveTenantVariants($tenant, $items);
|
||||
$purchaseItems = $this->buildPurchaseItemsPayload($items, $variants);
|
||||
|
||||
$purchase = DB::transaction(function () use ($request, $tenant, $data, $purchaseItems): Purchase {
|
||||
/** @var Purchase $purchase */
|
||||
$purchase = Purchase::query()->create([
|
||||
...$data,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'user_id' => $request->user()->id,
|
||||
]);
|
||||
|
||||
$purchase->items()->createMany($purchaseItems);
|
||||
|
||||
return $purchase->load(['items.variant.product', 'items.variant.definitions']);
|
||||
});
|
||||
|
||||
return PurchaseResource::make($purchase)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Request $request, Tenant $tenant, Purchase $compra): PurchaseResource
|
||||
{
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
|
||||
return PurchaseResource::make(
|
||||
$compra->loadMissing(['items.variant.product', 'items.variant.definitions'])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
* @return \Illuminate\Support\Collection<int, ProductVariant>
|
||||
*/
|
||||
protected function resolveTenantVariants(Tenant $tenant, array $items)
|
||||
{
|
||||
$variantIds = collect($items)
|
||||
->pluck('producto_variante_id')
|
||||
->filter()
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$variants = ProductVariant::query()
|
||||
->with('product')
|
||||
->whereIn('id', $variantIds)
|
||||
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $tenant->codigo))
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
if ($variants->count() !== $variantIds->count()) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => 'One or more product variants do not belong to the tenant.',
|
||||
]);
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
* @param \Illuminate\Support\Collection<int, ProductVariant> $variants
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
protected function buildPurchaseItemsPayload(array $items, $variants): array
|
||||
{
|
||||
return collect($items)
|
||||
->map(function (array $item) use ($variants): array {
|
||||
/** @var ProductVariant $variant */
|
||||
$variant = $variants->get((int) $item['producto_variante_id']);
|
||||
$quantity = (int) $item['cantidad'];
|
||||
$unitPrice = (float) $variant->precio;
|
||||
|
||||
return [
|
||||
'producto_variante_id' => $variant->getKey(),
|
||||
'cantidad' => $quantity,
|
||||
'precio_unitario' => $unitPrice,
|
||||
'discount_total' => null,
|
||||
'tax_total' => null,
|
||||
'total' => $unitPrice * $quantity,
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function resolveScopedPurchase(Tenant $tenant, int $userId, Purchase $purchase): Purchase
|
||||
{
|
||||
if ($purchase->tenant_codigo !== $tenant->codigo || $purchase->user_id !== $userId) {
|
||||
throw new NotFoundHttpException('Purchase not found for tenant.');
|
||||
}
|
||||
|
||||
return $purchase;
|
||||
}
|
||||
}
|
||||
56
app/Domains/Purchase/Models/Purchase.php
Normal file
56
app/Domains/Purchase/Models/Purchase.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Purchase\Models;
|
||||
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_codigo',
|
||||
'user_id',
|
||||
'status',
|
||||
'payment_status',
|
||||
'payment_method',
|
||||
])]
|
||||
class Purchase extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'compras';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<PurchaseItem, $this>
|
||||
*/
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseItem::class, 'compra_id');
|
||||
}
|
||||
}
|
||||
54
app/Domains/Purchase/Models/PurchaseItem.php
Normal file
54
app/Domains/Purchase/Models/PurchaseItem.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Purchase\Models;
|
||||
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'compra_id',
|
||||
'producto_variante_id',
|
||||
'cantidad',
|
||||
'precio_unitario',
|
||||
'discount_total',
|
||||
'tax_total',
|
||||
'total',
|
||||
])]
|
||||
class PurchaseItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'compra_items';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'compra_id' => 'integer',
|
||||
'producto_variante_id' => 'integer',
|
||||
'cantidad' => 'integer',
|
||||
'precio_unitario' => 'decimal:2',
|
||||
'discount_total' => 'decimal:2',
|
||||
'tax_total' => 'decimal:2',
|
||||
'total' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Purchase, $this>
|
||||
*/
|
||||
public function purchase(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Purchase::class, 'compra_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<ProductVariant, $this>
|
||||
*/
|
||||
public function variant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
|
||||
}
|
||||
}
|
||||
29
app/Domains/Purchase/Requests/StorePurchaseRequest.php
Normal file
29
app/Domains/Purchase/Requests/StorePurchaseRequest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Purchase\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StorePurchaseRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['sometimes', 'string', Rule::in(['pending', 'paid', 'cancelled'])],
|
||||
'payment_status' => ['sometimes', 'string', Rule::in(['pending', 'approved', 'rejected'])],
|
||||
'payment_method' => ['nullable', 'string', 'max:255'],
|
||||
'items' => ['required', 'array', 'min:1'],
|
||||
'items.*.producto_variante_id' => ['required', 'integer', 'exists:productos_variantes,id'],
|
||||
'items.*.cantidad' => ['required', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
49
app/Domains/Purchase/Resources/PurchaseItemResource.php
Normal file
49
app/Domains/Purchase/Resources/PurchaseItemResource.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Purchase\Resources;
|
||||
|
||||
use App\Domains\Catalog\Resources\ProductVariantDefinitionResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Purchase\Models\PurchaseItem
|
||||
*/
|
||||
class PurchaseItemResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$variant = $this->variant;
|
||||
$product = $variant?->product;
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'cantidad' => $this->cantidad,
|
||||
'precio_unitario' => $this->formatMoney($this->precio_unitario),
|
||||
'total' => $this->formatMoney($this->total),
|
||||
'product' => $product === null ? null : [
|
||||
'id' => $product->id,
|
||||
'nombre' => $product->nombre,
|
||||
'slug' => $product->slug,
|
||||
],
|
||||
'variant' => $variant === null ? null : [
|
||||
'id' => $variant->id,
|
||||
'nombre' => $variant->nombre,
|
||||
'slug' => $variant->slug,
|
||||
'definitions' => ProductVariantDefinitionResource::collection($variant->definitions),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function formatMoney(float|int|string|null $amount): ?string
|
||||
{
|
||||
if ($amount === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return number_format((float) $amount, 2, '.', '');
|
||||
}
|
||||
}
|
||||
49
app/Domains/Purchase/Resources/PurchaseResource.php
Normal file
49
app/Domains/Purchase/Resources/PurchaseResource.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Purchase\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Purchase\Models\Purchase
|
||||
*/
|
||||
class PurchaseResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$items = $this->resource->relationLoaded('items')
|
||||
? $this->resource->getRelation('items')
|
||||
: collect();
|
||||
|
||||
$subtotal = $items->reduce(
|
||||
fn (float $carry, $item): float => $carry + ((float) $item->precio_unitario * $item->cantidad),
|
||||
0.0,
|
||||
);
|
||||
|
||||
$total = $items->reduce(
|
||||
fn (float $carry, $item): float => $carry + (float) $item->total,
|
||||
0.0,
|
||||
);
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'tenant_codigo' => $this->tenant_codigo,
|
||||
'user_id' => $this->user_id,
|
||||
'status' => $this->status,
|
||||
'payment_status' => $this->payment_status,
|
||||
'payment_method' => $this->payment_method,
|
||||
'items' => PurchaseItemResource::collection($items),
|
||||
'subtotal' => $this->formatMoney($subtotal),
|
||||
'total' => $this->formatMoney($total),
|
||||
];
|
||||
}
|
||||
|
||||
protected function formatMoney(float|int|string|null $amount): string
|
||||
{
|
||||
return number_format((float) ($amount ?? 0), 2, '.', '');
|
||||
}
|
||||
}
|
||||
8
app/Domains/Purchase/routes/api.php
Normal file
8
app/Domains/Purchase/routes/api.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Purchase\Controllers\PurchaseController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(function (): void {
|
||||
Route::apiResource('compras', PurchaseController::class)->only(['index', 'store', 'show']);
|
||||
});
|
||||
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');
|
||||
}
|
||||
}
|
||||
54
app/Domains/Shared/Rules/ImageOrBase64Rule.php
Normal file
54
app/Domains/Shared/Rules/ImageOrBase64Rule.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Shared\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class ImageOrBase64Rule implements ValidationRule
|
||||
{
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if ($value instanceof UploadedFile) {
|
||||
$mime = $value->getMimeType();
|
||||
if (! str_starts_with((string) $mime, 'image/')) {
|
||||
$fail("The :attribute must be a valid image file.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$payload = trim($value);
|
||||
|
||||
if ($payload === '') {
|
||||
$fail("The :attribute must not be empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
$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-encoded image.");
|
||||
return;
|
||||
}
|
||||
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$detectedMime = $finfo->buffer($decoded);
|
||||
$mime = $detectedMime ?: $declaredMimeType;
|
||||
|
||||
if (! $mime || ! str_starts_with((string) $mime, 'image/')) {
|
||||
$fail("The :attribute must be a valid image (file or base64).");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$fail("The :attribute must be an image file or a base64-encoded image string.");
|
||||
}
|
||||
}
|
||||
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');
|
||||
|
||||
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\UpdateTenantRequest;
|
||||
use App\Domains\Tenant\Resources\TenantResource;
|
||||
use App\Domains\Tenant\Services\TenantService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class TenantController extends Controller
|
||||
{
|
||||
public function __construct(protected TenantService $tenantService)
|
||||
{
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$props = $validated['props'] ?? [];
|
||||
$tenant = $this->tenantService->create($request->validated());
|
||||
|
||||
$tenant = Tenant::createWithProps($validated, $props);
|
||||
|
||||
return TenantResource::make($tenant)->response()->setStatusCode(201);
|
||||
return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo']))->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$props = $validated['props'] ?? [];
|
||||
$tenant = $this->tenantService->update($tenant, $request->validated());
|
||||
|
||||
$tenant->updateWithProps($validated, $props);
|
||||
|
||||
return TenantResource::make($tenant);
|
||||
return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo']));
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Models\Product;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use LogicException;
|
||||
|
||||
#[Fillable([
|
||||
'codigo',
|
||||
'nombre',
|
||||
'dominio',
|
||||
'primary_color',
|
||||
'secondary_color',
|
||||
'danger_color',
|
||||
'header_footer_bg_color',
|
||||
'header_logo_id',
|
||||
'footer_logo_id',
|
||||
])]
|
||||
class Tenant extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @param array<string, mixed> $props
|
||||
* @return static
|
||||
*/
|
||||
public static function createWithProps(array $attributes, array $props = []): static
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
unset($attributes['props']);
|
||||
return 'codigo';
|
||||
}
|
||||
|
||||
/** @var static $tenant */
|
||||
$tenant = DB::transaction(function () use ($attributes, $props): self {
|
||||
/** @var static $createdTenant */
|
||||
$createdTenant = static::query()->create($attributes);
|
||||
$createdTenant->syncPropValues($props);
|
||||
/**
|
||||
* @return BelongsTo<Attachment, $this>
|
||||
*/
|
||||
public function headerLogo(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Attachment::class, 'header_logo_id');
|
||||
}
|
||||
|
||||
return $createdTenant;
|
||||
});
|
||||
|
||||
return $tenant;
|
||||
/**
|
||||
* @return BelongsTo<Attachment, $this>
|
||||
*/
|
||||
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 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,7 +2,7 @@
|
||||
|
||||
namespace App\Domains\Tenant\Requests;
|
||||
|
||||
use App\Domains\Tenant\Support\TenantPropRules;
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||
use Closure;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
@@ -35,6 +35,9 @@ class StoreTenantRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$logoRule = ['required', new ImageOrBase64Rule()];
|
||||
|
||||
|
||||
return [
|
||||
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
@@ -45,12 +48,17 @@ class StoreTenantRequest extends FormRequest
|
||||
$fail("The {$attribute} field must contain a valid domain or URL.");
|
||||
}
|
||||
},
|
||||
'nullable',
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
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)],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
namespace App\Domains\Tenant\Requests;
|
||||
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||
use App\Domains\Tenant\Support\TenantPropRules;
|
||||
use Closure;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
@@ -20,15 +20,17 @@ class UpdateTenantRequest extends FormRequest
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$rawDomain = $this->input('dominio');
|
||||
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
|
||||
if ($this->has('dominio')) {
|
||||
$rawDomain = $this->input('dominio');
|
||||
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
|
||||
|
||||
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
||||
&& $normalizedDomain === null;
|
||||
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
||||
&& $normalizedDomain === null;
|
||||
|
||||
$this->merge([
|
||||
'dominio' => $normalizedDomain,
|
||||
]);
|
||||
$this->merge([
|
||||
'dominio' => $normalizedDomain,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,14 +41,17 @@ class UpdateTenantRequest extends FormRequest
|
||||
/** @var Tenant|null $tenant */
|
||||
$tenant = $this->route('tenant');
|
||||
|
||||
$logoRule = ['nullable', new ImageOrBase64Rule()];
|
||||
|
||||
|
||||
return [
|
||||
'codigo' => [
|
||||
'required',
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('tenants', 'codigo')->ignore($tenant?->id),
|
||||
],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'nombre' => ['nullable', 'string', 'max:255'],
|
||||
'dominio' => [
|
||||
'bail',
|
||||
function (string $attribute, mixed $value, Closure $fail): void {
|
||||
@@ -59,7 +64,12 @@ class UpdateTenantRequest extends FormRequest
|
||||
'max:255',
|
||||
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
|
||||
{
|
||||
/**
|
||||
* @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>
|
||||
*/
|
||||
@@ -43,7 +20,13 @@ class TenantResource extends JsonResource
|
||||
'codigo' => $this->codigo,
|
||||
'nombre' => $this->nombre,
|
||||
'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\TenantController;
|
||||
use App\Domains\Tenant\Controllers\TenantPropController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('tenants/bootstrap/{dominio}', BootstrapTenantController::class)
|
||||
->where('dominio', '.*');
|
||||
|
||||
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
|
||||
{
|
||||
//
|
||||
\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",
|
||||
"laravel/framework": "^13.8",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/tinker": "^3.0"
|
||||
"laravel/tinker": "^3.0",
|
||||
"league/flysystem-aws-s3-v3": "3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"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",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "bf97dbe15104bcde9dd03530a8a289c8",
|
||||
"content-hash": "c1933c09dc4d90a389d19d7cffff4e43",
|
||||
"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",
|
||||
"version": "0.17.2",
|
||||
@@ -1805,6 +1956,62 @@
|
||||
},
|
||||
"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",
|
||||
"version": "3.31.0",
|
||||
@@ -2195,6 +2402,72 @@
|
||||
],
|
||||
"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",
|
||||
"version": "3.12.3",
|
||||
@@ -3931,6 +4204,77 @@
|
||||
],
|
||||
"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",
|
||||
"version": "v8.1.0",
|
||||
|
||||
@@ -56,8 +56,11 @@ return [
|
||||
'url' => env('AWS_URL'),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
'http' => [
|
||||
'verify' => env('AWS_HTTP_VERIFY', false),
|
||||
],
|
||||
'throw' => true,
|
||||
'report' => true,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
@@ -15,7 +15,13 @@ return new class extends Migration
|
||||
$table->id();
|
||||
$table->string('codigo')->unique();
|
||||
$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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -14,6 +14,7 @@ return new class extends Migration
|
||||
Schema::create('attribute_options', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('attribute_id')->constrained('productos_attributes')->cascadeOnUpdate()->cascadeOnDelete();
|
||||
$table->string('value');
|
||||
$table->string('label');
|
||||
$table->unsignedInteger('sort_order')->default(0);
|
||||
$table->json('metadata')->nullable();
|
||||
|
||||
@@ -11,11 +11,12 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tenant_prop_values', function (Blueprint $table) {
|
||||
Schema::create('carritos', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('tenant_codigo');
|
||||
$table->string('tenant_prop_codigo');
|
||||
$table->text('value')->nullable();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnUpdate()->cascadeOnDelete();
|
||||
$table->string('guest_token')->nullable();
|
||||
$table->string('status')->default('active');
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('tenant_codigo')
|
||||
@@ -24,13 +25,8 @@ return new class extends Migration
|
||||
->cascadeOnUpdate()
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->foreign('tenant_prop_codigo')
|
||||
->references('codigo')
|
||||
->on('tenant_props')
|
||||
->cascadeOnUpdate()
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->unique(['tenant_codigo', 'tenant_prop_codigo']);
|
||||
$table->unique(['tenant_codigo', 'user_id']);
|
||||
$table->unique(['tenant_codigo', 'guest_token']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,6 +35,6 @@ return new class extends Migration
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tenant_prop_values');
|
||||
Schema::dropIfExists('carritos');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?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']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('carrito_items');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?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('compras', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('tenant_codigo');
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnUpdate()->nullOnDelete();
|
||||
$table->enum('status', ['pending', 'paid', 'cancelled'])->default('pending');
|
||||
$table->enum('payment_status', ['pending', 'approved', 'rejected'])->default('pending');
|
||||
$table->string('payment_method')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('tenant_codigo')
|
||||
->references('codigo')
|
||||
->on('tenants')
|
||||
->cascadeOnUpdate()
|
||||
->restrictOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('compras');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
<?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('compra_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('compra_id')->constrained('compras')->cascadeOnUpdate()->cascadeOnDelete();
|
||||
$table->unsignedBigInteger('producto_variante_id');
|
||||
$table->unsignedInteger('cantidad');
|
||||
$table->decimal('precio_unitario', 10, 2);
|
||||
$table->decimal('discount_total', 10, 2)->nullable();
|
||||
$table->decimal('tax_total', 10, 2)->nullable();
|
||||
$table->decimal('total', 10, 2);
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('producto_variante_id')
|
||||
->references('id')
|
||||
->on('productos_variantes')
|
||||
->cascadeOnUpdate()
|
||||
->restrictOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('compra_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']);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?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('variantes_attachments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('variante_id')
|
||||
->constrained('productos_variantes')
|
||||
->cascadeOnDelete();
|
||||
$table->foreignId('attachment_id')
|
||||
->constrained('attachments')
|
||||
->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['variante_id', 'attachment_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('variantes_attachments');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
<?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
|
||||
{
|
||||
// 1. Rename table productos_attributes to attribute
|
||||
Schema::rename('productos_attributes', 'attribute');
|
||||
|
||||
// 2. Rename table productos_variantes_definiciones to productos_variantes_values
|
||||
Schema::rename('productos_variantes_definiciones', 'productos_variantes_values');
|
||||
|
||||
// 3. Create products_attributes intermediate table
|
||||
Schema::create('products_attributes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('product_id');
|
||||
$table->unsignedBigInteger('attribute_id');
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('product_id')
|
||||
->references('id')
|
||||
->on('productos')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->foreign('attribute_id')
|
||||
->references('id')
|
||||
->on('attribute')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->unique(['product_id', 'attribute_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('products_attributes');
|
||||
Schema::rename('productos_variantes_values', 'productos_variantes_definiciones');
|
||||
Schema::rename('attribute', 'productos_attributes');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?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('productos_attachments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('producto_id')
|
||||
->constrained('productos')
|
||||
->cascadeOnDelete();
|
||||
$table->foreignId('attachment_id')
|
||||
->constrained('attachments')
|
||||
->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['producto_id', 'attachment_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('productos_attachments');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?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::table('productos', function (Blueprint $table) {
|
||||
$table->foreignId('brand_id')
|
||||
->nullable()
|
||||
->after('categoria_id')
|
||||
->constrained('brands')
|
||||
->cascadeOnUpdate()
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('productos', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('brand_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
114
database/seeders/AttributeSeeder.php
Normal file
114
database/seeders/AttributeSeeder.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\Product;
|
||||
use App\Domains\Shared\Enums\FieldType;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class AttributeSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$tenants = Tenant::all();
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
// Seed Talle (Size - Text options) attribute
|
||||
$existingTalle = Attribute::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->where('codigo', 'talle')
|
||||
->first();
|
||||
|
||||
if ($existingTalle) {
|
||||
Product::deleteAttribute($existingTalle);
|
||||
}
|
||||
|
||||
Product::createAttribute($tenant, [
|
||||
'codigo' => 'talle',
|
||||
'nombre' => 'Talle',
|
||||
'type' => FieldType::Select->value,
|
||||
'is_required' => true,
|
||||
'options' => [
|
||||
['value' => 'S', 'label' => 'S', 'sort_order' => 1],
|
||||
['value' => 'M', 'label' => 'M', 'sort_order' => 2],
|
||||
['value' => 'L', 'label' => 'L', 'sort_order' => 3],
|
||||
['value' => 'XL', 'label' => 'XL', 'sort_order' => 4],
|
||||
],
|
||||
]);
|
||||
|
||||
// Seed Talle Numérico (Numeric Size options) attribute
|
||||
$existingTalleNumerico = Attribute::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->where('codigo', 'talle_numerico')
|
||||
->first();
|
||||
|
||||
if ($existingTalleNumerico) {
|
||||
Product::deleteAttribute($existingTalleNumerico);
|
||||
}
|
||||
|
||||
Product::createAttribute($tenant, [
|
||||
'codigo' => 'talle_numerico',
|
||||
'nombre' => 'Talle Numérico',
|
||||
'type' => FieldType::Select->value,
|
||||
'is_required' => true,
|
||||
'options' => [
|
||||
['value' => '38', 'label' => '38', 'sort_order' => 1],
|
||||
['value' => '40', 'label' => '40', 'sort_order' => 2],
|
||||
['value' => '42', 'label' => '42', 'sort_order' => 3],
|
||||
['value' => '44', 'label' => '44', 'sort_order' => 4],
|
||||
],
|
||||
]);
|
||||
|
||||
// Seed Color attribute
|
||||
$existingColor = Attribute::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->where('codigo', 'color')
|
||||
->first();
|
||||
|
||||
if ($existingColor) {
|
||||
Product::deleteAttribute($existingColor);
|
||||
}
|
||||
|
||||
Product::createAttribute($tenant, [
|
||||
'codigo' => 'color',
|
||||
'nombre' => 'Color',
|
||||
'type' => FieldType::Select->value,
|
||||
'is_required' => true,
|
||||
'metadata_schema' => [
|
||||
'hex' => ['type' => 'string'],
|
||||
],
|
||||
'options' => [
|
||||
[
|
||||
'value' => 'Negro',
|
||||
'label' => 'Negro',
|
||||
'sort_order' => 1,
|
||||
'metadata' => ['hex' => '#000000'],
|
||||
],
|
||||
[
|
||||
'value' => 'Gris',
|
||||
'label' => 'Gris',
|
||||
'sort_order' => 2,
|
||||
'metadata' => ['hex' => '#808080'],
|
||||
],
|
||||
[
|
||||
'value' => 'Blanco',
|
||||
'label' => 'Blanco',
|
||||
'sort_order' => 3,
|
||||
'metadata' => ['hex' => '#FFFFFF'],
|
||||
],
|
||||
[
|
||||
'value' => 'Azul',
|
||||
'label' => 'Azul',
|
||||
'sort_order' => 4,
|
||||
'metadata' => ['hex' => '#0000FF'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
40
database/seeders/BrandSeeder.php
Normal file
40
database/seeders/BrandSeeder.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Domains\Catalog\Models\Brand;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class BrandSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$brands = [
|
||||
'Le Coq',
|
||||
'Adidas',
|
||||
'Under Armour',
|
||||
'Rosario Central',
|
||||
'Topper',
|
||||
'Nike',
|
||||
];
|
||||
|
||||
$tenants = Tenant::all();
|
||||
|
||||
if ($tenants->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
foreach ($brands as $nombre) {
|
||||
Brand::firstOrCreate([
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'nombre' => $nombre,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
database/seeders/CategorySeeder.php
Normal file
29
database/seeders/CategorySeeder.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CategorySeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$categories = [
|
||||
'Remeras',
|
||||
'Pantalones',
|
||||
'Zapatillas',
|
||||
'Buzos'
|
||||
];
|
||||
|
||||
foreach ($categories as $nombre) {
|
||||
Category::firstOrCreate([
|
||||
'nombre' => $nombre,
|
||||
'tenant_code' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,5 +21,13 @@ class DatabaseSeeder extends Seeder
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$this->call([
|
||||
TenantSeeder::class,
|
||||
AttributeSeeder::class,
|
||||
CategorySeeder::class,
|
||||
BrandSeeder::class,
|
||||
ProductCatalogFromImagesSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
354
database/seeders/ProductCatalogFromImagesSeeder.php
Normal file
354
database/seeders/ProductCatalogFromImagesSeeder.php
Normal file
@@ -0,0 +1,354 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\Brand;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\Product;
|
||||
use App\Domains\Catalog\Services\ProductService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class ProductCatalogFromImagesSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* @var array<string, array{price: int, stock: int}>
|
||||
*/
|
||||
private const PRODUCT_CATALOG = [
|
||||
'buzo_blanco_adidas' => ['price' => 89999, 'stock' => 12],
|
||||
'buzo_gris_nike' => ['price' => 94999, 'stock' => 10],
|
||||
'buzo_gris_under_armour' => ['price' => 96999, 'stock' => 8],
|
||||
'buzo_negro_adidas' => ['price' => 91999, 'stock' => 9],
|
||||
'camiseta_rosario_central' => ['price' => 69999, 'stock' => 20],
|
||||
'pantalon_blanco' => ['price' => 55999, 'stock' => 14],
|
||||
'pantalon_negro' => ['price' => 57999, 'stock' => 14],
|
||||
'remera_blanca' => ['price' => 35999, 'stock' => 22],
|
||||
'remera_negra' => ['price' => 36999, 'stock' => 22],
|
||||
'zapatillas_lecoq' => ['price' => 109999, 'stock' => 6],
|
||||
'zapatillas_topper' => ['price' => 104999, 'stock' => 7],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const CLOTHING_SIZES = ['S', 'M', 'L', 'XL'];
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const SHOE_SIZES = ['38', '40', '42', '44'];
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const IGNORED_FILES = [
|
||||
'istockphoto-1675347112-2048x2048.jpg',
|
||||
];
|
||||
|
||||
public function __construct(private readonly ProductService $productService)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$tenant = Tenant::query()->where('codigo', 'sonder')->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new RuntimeException("Tenant 'sonder' not found.");
|
||||
}
|
||||
|
||||
$catalogPath = storage_path('public/catalog');
|
||||
|
||||
if (! is_dir($catalogPath)) {
|
||||
throw new RuntimeException("Catalog directory not found at path: {$catalogPath}");
|
||||
}
|
||||
|
||||
$files = collect(File::files($catalogPath))
|
||||
->filter(fn (\SplFileInfo $file) => ! in_array($file->getFilename(), self::IGNORED_FILES, true))
|
||||
->values();
|
||||
|
||||
if ($files->isEmpty()) {
|
||||
throw new RuntimeException("Catalog directory is empty at path: {$catalogPath}");
|
||||
}
|
||||
|
||||
$groupedFiles = $files->groupBy(function (\SplFileInfo $file): string {
|
||||
return $this->normalizeGroupKey($file->getBasename('.'.$file->getExtension()));
|
||||
});
|
||||
|
||||
$productsToSeed = collect(self::PRODUCT_CATALOG)
|
||||
->map(function (array $pricing, string $groupKey) use ($groupedFiles, $tenant): array {
|
||||
$filesForGroup = $groupedFiles->get($groupKey);
|
||||
|
||||
if ($filesForGroup === null || $filesForGroup->isEmpty()) {
|
||||
throw new RuntimeException("No images found for catalog group '{$groupKey}'.");
|
||||
}
|
||||
|
||||
$metadata = $this->parseGroupKey($groupKey);
|
||||
|
||||
return [
|
||||
'group_key' => $groupKey,
|
||||
'pricing' => $pricing,
|
||||
'tenant' => $tenant,
|
||||
'files' => $filesForGroup->all(),
|
||||
'metadata' => $metadata,
|
||||
];
|
||||
})
|
||||
->values();
|
||||
|
||||
$existingProducts = Product::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->whereIn('slug', $productsToSeed->pluck('metadata.product_slug'))
|
||||
->with(['attachments', 'variants.attachments'])
|
||||
->get();
|
||||
|
||||
foreach ($existingProducts as $existingProduct) {
|
||||
$this->productService->delete($existingProduct);
|
||||
}
|
||||
|
||||
foreach ($productsToSeed as $catalogProduct) {
|
||||
$this->seedProduct(
|
||||
$tenant,
|
||||
$catalogProduct['metadata'],
|
||||
$catalogProduct['pricing'],
|
||||
$catalogProduct['files'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{attribute_codes: array<int, string>, brand_name: string|null, category_name: string, color: string|null, description: string, product_name: string, product_slug: string, type: string} $metadata
|
||||
* @param array{price: int, stock: int} $pricing
|
||||
* @param array<int, \SplFileInfo> $files
|
||||
*/
|
||||
private function seedProduct(Tenant $tenant, array $metadata, array $pricing, array $files): void
|
||||
{
|
||||
$brand = null;
|
||||
|
||||
if ($metadata['brand_name'] !== null) {
|
||||
$brand = Brand::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->where('nombre', $metadata['brand_name'])
|
||||
->first();
|
||||
|
||||
if (! $brand instanceof Brand) {
|
||||
throw new RuntimeException("Brand '{$metadata['brand_name']}' not found for tenant '{$tenant->codigo}'.");
|
||||
}
|
||||
}
|
||||
|
||||
$category = Category::query()
|
||||
->where('nombre', $metadata['category_name'])
|
||||
->whereNull('tenant_code')
|
||||
->first();
|
||||
|
||||
if (! $category instanceof Category) {
|
||||
throw new RuntimeException("Category '{$metadata['category_name']}' not found.");
|
||||
}
|
||||
|
||||
$attributeIds = Attribute::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->whereIn('codigo', $metadata['attribute_codes'])
|
||||
->pluck('id', 'codigo');
|
||||
|
||||
if ($attributeIds->count() !== count($metadata['attribute_codes'])) {
|
||||
throw new RuntimeException("Missing attributes for product '{$metadata['product_slug']}'.");
|
||||
}
|
||||
|
||||
$product = $this->productService->create($tenant, [
|
||||
'categoria_id' => $category->id,
|
||||
'brand_id' => $brand?->id,
|
||||
'slug' => $metadata['product_slug'],
|
||||
'nombre' => $metadata['product_name'],
|
||||
'descripcion' => $metadata['description'],
|
||||
'precio' => $pricing['price'],
|
||||
'attribute_ids' => array_values($attributeIds->all()),
|
||||
]);
|
||||
|
||||
$sizes = $metadata['type'] === 'zapatillas'
|
||||
? self::SHOE_SIZES
|
||||
: self::CLOTHING_SIZES;
|
||||
|
||||
$images = array_map(
|
||||
fn (\SplFileInfo $file): UploadedFile => $this->createUploadedFile($file->getPathname()),
|
||||
$files
|
||||
);
|
||||
|
||||
foreach ($sizes as $size) {
|
||||
$definitions = [];
|
||||
|
||||
if (isset($attributeIds['color']) && $metadata['color'] !== null) {
|
||||
$definitions[] = [
|
||||
'attribute_id' => $attributeIds['color'],
|
||||
'value' => $metadata['color'],
|
||||
];
|
||||
}
|
||||
|
||||
$sizeAttributeCode = $metadata['type'] === 'zapatillas'
|
||||
? 'talle_numerico'
|
||||
: 'talle';
|
||||
|
||||
$definitions[] = [
|
||||
'attribute_id' => $attributeIds[$sizeAttributeCode],
|
||||
'value' => $size,
|
||||
];
|
||||
|
||||
$this->productService->createVariant($product, [
|
||||
'slug' => $metadata['product_slug'].'-'.Str::slug((string) $size),
|
||||
'nombre' => $metadata['product_name'].' - Talle '.$size,
|
||||
'stock' => $pricing['stock'],
|
||||
'descripcion' => $metadata['description'],
|
||||
'precio' => $pricing['price'],
|
||||
'definitions' => $definitions,
|
||||
'images' => $images,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{attribute_codes: array<int, string>, brand_name: string|null, category_name: string, color: string|null, description: string, product_name: string, product_slug: string, type: string}
|
||||
*/
|
||||
private function parseGroupKey(string $groupKey): array
|
||||
{
|
||||
if ($groupKey === 'camiseta_rosario_central') {
|
||||
return [
|
||||
'type' => 'camiseta',
|
||||
'category_name' => 'Remeras',
|
||||
'brand_name' => 'Rosario Central',
|
||||
'color' => null,
|
||||
'attribute_codes' => ['talle'],
|
||||
'product_slug' => $groupKey,
|
||||
'product_name' => 'Camiseta Rosario Central',
|
||||
'description' => 'Camiseta Rosario Central con variantes por talle.',
|
||||
];
|
||||
}
|
||||
|
||||
$segments = explode('_', $groupKey);
|
||||
$type = array_shift($segments);
|
||||
|
||||
if (! is_string($type) || $type === '') {
|
||||
throw new RuntimeException("Unable to infer product type from '{$groupKey}'.");
|
||||
}
|
||||
|
||||
$color = null;
|
||||
$brandSegments = $segments;
|
||||
|
||||
if (isset($segments[0]) && in_array($segments[0], ['blanco', 'blanca', 'negro', 'negra', 'gris'], true)) {
|
||||
$color = $this->normalizeColor($segments[0]);
|
||||
$brandSegments = array_slice($segments, 1);
|
||||
}
|
||||
|
||||
$brandKey = implode('_', $brandSegments);
|
||||
$brandName = $brandKey !== ''
|
||||
? $this->normalizeBrandName($brandKey)
|
||||
: null;
|
||||
$categoryName = $this->resolveCategoryName($type);
|
||||
$productName = $this->buildProductName($type, $color, $brandName);
|
||||
$description = $this->buildDescription($type, $color, $brandName);
|
||||
$attributeCodes = $type === 'zapatillas'
|
||||
? ['talle_numerico']
|
||||
: ['talle'];
|
||||
|
||||
if ($color !== null) {
|
||||
array_unshift($attributeCodes, 'color');
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => $type,
|
||||
'category_name' => $categoryName,
|
||||
'brand_name' => $brandName,
|
||||
'color' => $color,
|
||||
'attribute_codes' => $attributeCodes,
|
||||
'product_slug' => $groupKey,
|
||||
'product_name' => $productName,
|
||||
'description' => $description,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeGroupKey(string $basename): string
|
||||
{
|
||||
$normalized = Str::of($basename)
|
||||
->lower()
|
||||
->replace('-', '_')
|
||||
->replace(' ', '_')
|
||||
->replaceMatches('/_\d+$/', '')
|
||||
->value();
|
||||
|
||||
return str_replace('nije', 'nike', $normalized);
|
||||
}
|
||||
|
||||
private function normalizeColor(string $color): string
|
||||
{
|
||||
return match ($color) {
|
||||
'blanco', 'blanca' => 'Blanco',
|
||||
'negro', 'negra' => 'Negro',
|
||||
'gris' => 'Gris',
|
||||
default => throw new RuntimeException("Unsupported color '{$color}'."),
|
||||
};
|
||||
}
|
||||
|
||||
private function normalizeBrandName(string $brandKey): string
|
||||
{
|
||||
return match ($brandKey) {
|
||||
'adidas' => 'Adidas',
|
||||
'nike' => 'Nike',
|
||||
'under_armour' => 'Under Armour',
|
||||
'topper' => 'Topper',
|
||||
'lecoq' => 'Le Coq',
|
||||
default => throw new RuntimeException("Unsupported brand '{$brandKey}'."),
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveCategoryName(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'buzo' => 'Buzos',
|
||||
'remera', 'camiseta' => 'Remeras',
|
||||
'pantalon' => 'Pantalones',
|
||||
'zapatillas' => 'Zapatillas',
|
||||
default => throw new RuntimeException("Unsupported product type '{$type}'."),
|
||||
};
|
||||
}
|
||||
|
||||
private function buildProductName(string $type, ?string $color, ?string $brandName): string
|
||||
{
|
||||
$typeLabel = match ($type) {
|
||||
'buzo' => 'Buzo',
|
||||
'remera' => 'Remera',
|
||||
'camiseta' => 'Camiseta',
|
||||
'pantalon' => 'Pantalon',
|
||||
'zapatillas' => 'Zapatillas',
|
||||
default => Str::headline($type),
|
||||
};
|
||||
|
||||
return trim(collect([$typeLabel, $color, $brandName])->filter()->implode(' '));
|
||||
}
|
||||
|
||||
private function buildDescription(string $type, ?string $color, ?string $brandName): string
|
||||
{
|
||||
$category = Str::lower($this->resolveCategoryName($type));
|
||||
$colorText = $color !== null ? " color {$color}" : '';
|
||||
$brandText = $brandName !== null ? " {$brandName}" : '';
|
||||
|
||||
return "Producto seed de {$category}{$brandText}{$colorText} con variantes por talle.";
|
||||
}
|
||||
|
||||
private function createUploadedFile(string $path): UploadedFile
|
||||
{
|
||||
$mimeType = mime_content_type($path);
|
||||
|
||||
return new UploadedFile(
|
||||
$path,
|
||||
basename($path),
|
||||
is_string($mimeType) ? $mimeType : null,
|
||||
null,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
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="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="DB_CONNECTION" value="mysql"/>
|
||||
<env name="DB_DATABASE" value="shopit_test"/>
|
||||
<env name="DB_URL" value=""/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<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 |
@@ -8,4 +8,7 @@ Route::get('/user', function (Request $request) {
|
||||
})->middleware('auth:sanctum');
|
||||
|
||||
require __DIR__.'/../app/Domains/Catalog/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/Tenant/routes/api.php';
|
||||
|
||||
BIN
storage/public/catalog/buzo_blanco_adidas.jpg
Normal file
BIN
storage/public/catalog/buzo_blanco_adidas.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
storage/public/catalog/buzo_blanco_adidas_1.jpg
Normal file
BIN
storage/public/catalog/buzo_blanco_adidas_1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
storage/public/catalog/buzo_gris_nije.jpg
Normal file
BIN
storage/public/catalog/buzo_gris_nije.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
BIN
storage/public/catalog/buzo_gris_nike_1.jpg
Normal file
BIN
storage/public/catalog/buzo_gris_nike_1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user