refactor(backend): reorganize domains into Core, Commerce, Ticketing and Shared

This commit is contained in:
2026-09-18 10:14:02 -03:00
parent 4659c1049d
commit 1241e1f7e8
425 changed files with 0 additions and 0 deletions

View 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';
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Attachable\Exceptions;
use RuntimeException;
class AttachmentStorageException extends RuntimeException
{
}

View File

@@ -0,0 +1,78 @@
<?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\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Facades\Storage;
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',
];
}
public function cropVariants(): HasMany
{
return $this->hasMany(AttachmentCrop::class);
}
public function desktopCrop(): HasOne
{
return $this->hasOne(AttachmentCrop::class)
->where('variant', AttachmentCrop::DESKTOP);
}
public function mobileCrop(): HasOne
{
return $this->hasOne(AttachmentCrop::class)
->where('variant', AttachmentCrop::MOBILE);
}
public function cropSource(): HasOne
{
return $this->hasOne(AttachmentCrop::class, 'cropped_attachment_id');
}
/**
* Get the pre-signed temporary S3 URL for this attachment.
*/
public function getTemporaryUrl(int $expiresInMinutes = 10): string
{
return Storage::disk('s3')->temporaryUrl(
$this->path,
now()->addMinutes($expiresInMinutes)
);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domains\Attachable\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'attachment_id',
'variant',
'crop_horizontal',
'crop_vertical',
'cropped_attachment_id',
])]
class AttachmentCrop extends Model
{
public const DESKTOP = 'desktop';
public const MOBILE = 'mobile';
public const VARIANTS = [self::DESKTOP, self::MOBILE];
protected function casts(): array
{
return [
'attachment_id' => 'integer',
'crop_horizontal' => 'array',
'crop_vertical' => 'array',
'cropped_attachment_id' => 'integer',
];
}
public function attachment(): BelongsTo
{
return $this->belongsTo(Attachment::class);
}
public function croppedAttachment(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'cropped_attachment_id');
}
}

View File

@@ -0,0 +1,574 @@
<?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 App\Domains\Attachable\Models\AttachmentCrop;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\Mime\MimeTypes;
use Throwable;
class AttachmentService
{
/**
* @param array<string, array{crop_horizontal: array<string, mixed>, crop_vertical: array<string, mixed>}> $crops
*/
public function storeCroppedImageVariants(
UploadedFile|string $image,
string $path,
array $crops,
): Attachment {
$crops = $this->validateCropVariants($crops);
$storedPaths = [];
try {
return DB::transaction(function () use (
$image,
$path,
$crops,
&$storedPaths,
): Attachment {
$original = $this->store($image, $path);
$storedPaths[] = $original->path;
$contents = $this->imageContents($image);
foreach ($crops as $variant => $crop) {
$cropped = $this->storeCropVariant($contents, $path, $crop);
$storedPaths[] = $cropped->path;
$original->cropVariants()->create([
'variant' => $variant,
'crop_horizontal' => $crop['crop_horizontal'],
'crop_vertical' => $crop['crop_vertical'],
'cropped_attachment_id' => $cropped->id,
]);
}
return $original->load('cropVariants.croppedAttachment');
});
} catch (Throwable $throwable) {
if ($storedPaths !== []) {
Storage::disk('s3')->delete(array_values(array_unique($storedPaths)));
}
throw $throwable;
}
}
/**
* @param array<string, array{crop_horizontal: array<string, mixed>, crop_vertical: array<string, mixed>}> $crops
*/
public function updateImageCropVariants(
Attachment $original,
array $crops,
): Attachment {
if ($original->type !== AttachmentType::Image) {
throw new AttachmentStorageException('Only image attachments can be cropped.');
}
$crops = $this->validateCropVariants($crops);
$contents = Storage::disk('s3')->get($original->path);
if (! is_string($contents) || $contents === '') {
throw new AttachmentStorageException('The original image could not be read from the s3 disk.');
}
$directory = trim(str_replace('\\', '/', dirname($original->path)), './');
$directory = $directory !== '' ? $directory : 'attachments';
$previousCrops = $original->cropVariants()->with('croppedAttachment')->get();
$storedCrops = [];
try {
foreach ($crops as $variant => $crop) {
$storedCrops[$variant] = $this->storeCropVariant($contents, $directory, $crop);
}
DB::transaction(function () use ($original, $crops, $storedCrops): void {
foreach ($crops as $variant => $crop) {
$original->cropVariants()->updateOrCreate(
['variant' => $variant],
[
'crop_horizontal' => $crop['crop_horizontal'],
'crop_vertical' => $crop['crop_vertical'],
'cropped_attachment_id' => $storedCrops[$variant]->id,
]
);
}
});
} catch (Throwable $throwable) {
foreach ($storedCrops as $storedCrop) {
$this->delete($storedCrop);
}
throw $throwable;
}
foreach ($previousCrops as $previousCrop) {
if ($previousCrop->croppedAttachment !== null) {
$this->delete($previousCrop->croppedAttachment);
}
}
return $original->refresh()->load('cropVariants.croppedAttachment');
}
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
{
$croppedAttachments = $attachment->cropVariants()
->with('croppedAttachment')
->get()
->pluck('croppedAttachment')
->filter();
$paths = $croppedAttachments
->pluck('path')
->prepend($attachment->path)
->filter()
->unique()
->values()
->all();
$deleted = Storage::disk('s3')->delete(
count($paths) === 1 ? $paths[0] : $paths
);
if (! $deleted) {
throw new AttachmentStorageException('No se pudieron eliminar los archivos del disco s3.');
}
DB::transaction(function () use ($attachment, $croppedAttachments): void {
$attachment->delete();
Attachment::query()->whereKey($croppedAttachments->pluck('id'))->delete();
});
}
public function copy(Attachment $source, string $path): Attachment
{
$normalizedPath = $this->normalizeDirectory($path);
if ($normalizedPath === '') {
throw new AttachmentStorageException('The attachment path cannot be empty.');
}
$key = (string) Str::uuid();
$storedPath = $normalizedPath.'/'.$this->buildStoredFilename($key, (string) $source->extension);
$copied = Storage::disk('s3')->copy($source->path, $storedPath);
if (! $copied) {
throw new AttachmentStorageException('No se pudo copiar el archivo en el disco s3.');
}
try {
/** @var Attachment $attachment */
$attachment = Attachment::query()->create([
'key' => $key,
'path' => $storedPath,
'filename' => $source->filename,
'type' => $source->type,
'mime_type' => $source->mime_type,
'extension' => $source->extension,
'size' => $source->size,
]);
return $attachment;
} catch (Throwable $throwable) {
Storage::disk('s3')->delete($storedPath);
throw $throwable;
}
}
protected function normalizeDirectory(string $path): string
{
return trim($path, '/');
}
/**
* @param array<string, array{crop_horizontal?: mixed, crop_vertical?: mixed}> $crops
* @return array<string, array{crop_horizontal: array{start_percentage: float, end_percentage: float}, crop_vertical: array{start_percentage: float, end_percentage: float}}>
*/
protected function validateCropVariants(array $crops): array
{
$validated = [];
foreach (AttachmentCrop::VARIANTS as $variant) {
$crop = $crops[$variant] ?? null;
if (! is_array($crop)) {
throw new AttachmentStorageException("The {$variant} crop is required.");
}
$horizontal = $crop['crop_horizontal'] ?? null;
$vertical = $crop['crop_vertical'] ?? null;
if (! is_array($horizontal) || ! is_array($vertical)) {
throw new AttachmentStorageException(
"The {$variant} crop must contain crop_horizontal and crop_vertical ranges."
);
}
$validated[$variant] = [
'crop_horizontal' => $this->validateCropRange($horizontal, "{$variant} horizontal"),
'crop_vertical' => $this->validateCropRange($vertical, "{$variant} vertical"),
];
}
return $validated;
}
/**
* @param array{crop_horizontal: array<string, mixed>, crop_vertical: array<string, mixed>} $crop
*/
protected function storeCropVariant(string $contents, string $path, array $crop): Attachment
{
$croppedContents = $this->cropImage(
$contents,
$crop['crop_horizontal'],
$crop['crop_vertical'],
);
return $this->store(
'data:'.$croppedContents['mime_type'].';base64,'.base64_encode($croppedContents['contents']),
$path,
);
}
/**
* @param array{start_percentage?: mixed, end_percentage?: mixed} $range
* @return array{start_percentage: float, end_percentage: float}
*/
protected function validateCropRange(array $range, string $axis): array
{
$start = $range['start_percentage'] ?? null;
$end = $range['end_percentage'] ?? null;
if (! is_numeric($start) || ! is_numeric($end)) {
throw new AttachmentStorageException(
"The {$axis} crop range must contain numeric start_percentage and end_percentage values."
);
}
$start = (float) $start;
$end = (float) $end;
if (! is_finite($start) || ! is_finite($end) || $start < 0 || $end > 100 || $start >= $end) {
throw new AttachmentStorageException(
"The {$axis} crop range must satisfy 0 <= start_percentage < end_percentage <= 100."
);
}
return [
'start_percentage' => $start,
'end_percentage' => $end,
];
}
protected function imageContents(UploadedFile|string $image): string
{
if (is_string($image)) {
return $this->decodeBase64File($image)['data'];
}
$contents = file_get_contents($image->getRealPath());
if (! is_string($contents) || $contents === '') {
throw new AttachmentStorageException('The image content cannot be empty.');
}
return $contents;
}
/**
* @return array{contents: string, mime_type: string}
*/
protected function cropImage(
string $contents,
array $cropHorizontal,
array $cropVertical,
): array {
$source = @imagecreatefromstring($contents);
if ($source === false) {
throw new AttachmentStorageException('The attachment must contain a valid image.');
}
$width = imagesx($source);
$height = imagesy($source);
$x = min($width - 1, (int) floor($width * $cropHorizontal['start_percentage'] / 100));
$right = min($width, (int) ceil($width * $cropHorizontal['end_percentage'] / 100));
$y = min($height - 1, (int) floor($height * $cropVertical['start_percentage'] / 100));
$bottom = min($height, (int) ceil($height * $cropVertical['end_percentage'] / 100));
$cropped = imagecrop($source, [
'x' => $x,
'y' => $y,
'width' => $right - $x,
'height' => $bottom - $y,
]);
if ($cropped === false) {
throw new AttachmentStorageException('The image could not be cropped.');
}
return $this->encodeImage($cropped, $contents);
}
/**
* @return array{contents: string, mime_type: string}
*/
protected function encodeImage(\GdImage $image, string $originalContents): array
{
$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($originalContents);
$mimeType = is_string($mimeType) ? strtolower($mimeType) : '';
ob_start();
try {
$encoded = match ($mimeType) {
'image/jpeg' => imagejpeg($image, null, 90),
'image/gif' => imagegif($image),
'image/webp' => imagewebp($image, null, 90),
'image/avif' => function_exists('imageavif') && imageavif($image, null, 90),
default => imagepng($image),
};
$output = ob_get_contents();
} finally {
ob_end_clean();
}
if (! $encoded || ! is_string($output) || $output === '') {
throw new AttachmentStorageException('The cropped image could not be encoded.');
}
return [
'contents' => $output,
'mime_type' => match ($mimeType) {
'image/jpeg', 'image/gif', 'image/webp', 'image/avif' => $mimeType,
default => 'image/png',
},
];
}
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;
}
}

View File

@@ -0,0 +1,33 @@
# Dominio Attachable
## Propósito
Centraliza el almacenamiento y la metadata de archivos adjuntos. Acepta archivos subidos o contenido Base64, los persiste en S3 y registra su tipo, MIME, extensión, tamaño, nombre original y clave única. Para imágenes también permite guardar un original junto con variantes recortadas para desktop y mobile.
## Componentes principales
- `Models/Attachment.php`: representa un adjunto y genera URL temporales de acceso.
- `Models/AttachmentCrop.php`: relaciona un original con su crop desktop o mobile y la imagen generada.
- `Services/AttachmentService.php`: almacena, copia y elimina archivos, compensando en S3 si falla la escritura en base de datos.
- `Enums/AttachmentType.php`: clasifica imágenes, videos, PDF, audio, documentos y otros archivos.
- `Exceptions/AttachmentStorageException.php`: expresa fallos propios del almacenamiento.
## Flujo principal
1. El consumidor entrega un `UploadedFile` o una cadena Base64 y un directorio.
2. El servicio valida el contenido, detecta MIME/extensión y genera una clave UUID.
3. El archivo se guarda en el disco `s3`.
4. Se crea el registro `Attachment`; ante error se elimina el objeto que había sido subido.
## API y dependencias
No expone rutas HTTP propias. Lo consumen otros dominios, especialmente `Catalog` y `Tenant`. Depende de Laravel Storage, Symfony Mime y del modelo `Attachment`.
## Consideraciones
- El directorio no puede quedar vacío después de normalizarlo.
- Cada eje del crop guarda `start_percentage` y `end_percentage`, cumpliendo `0 <= start < end <= 100`.
- `attachment_crops` guarda una fila por variante con los rangos horizontal/vertical y el attachment procesado.
- Al eliminar el original mediante el servicio también se eliminan todas sus variantes recortadas.
- La eliminación se considera fallida si S3 no confirma el borrado.
- Las URL generadas son temporales; el vencimiento predeterminado es de 10 minutos.

View File

@@ -0,0 +1,33 @@
<?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';
case EventDate = 'event_date';
public function supportsOptions(): bool
{
return in_array($this, [self::Select, self::Multiselect, self::EventDate], true);
}
public function usesDynamicOptions(): bool
{
return $this === self::EventDate;
}
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\EntryFormResource;
use App\Domains\Forms\Services\EntryFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class EntryFormController extends Controller
{
public function __construct(protected EntryFormService $entryFormService) {}
public function __invoke(Request $request): EntryFormResource
{
return EntryFormResource::make(
$this->entryFormService->get(
$request->user('sanctum')->tenant()->firstOrFail()
)
);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\EventFormResource;
use App\Domains\Forms\Services\EventFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class EventFormController extends Controller
{
public function __construct(protected EventFormService $eventFormService) {}
public function __invoke(Request $request): EventFormResource
{
return EventFormResource::make(
$this->eventFormService->get(
$request->user('sanctum')->tenant()->firstOrFail()
)
);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\FoodFormResource;
use App\Domains\Forms\Services\FoodFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class FoodFormController extends Controller
{
public function __construct(protected FoodFormService $foodFormService) {}
public function __invoke(Request $request): FoodFormResource
{
return FoodFormResource::make(
$this->foodFormService->get(
$request->user('sanctum')->tenant()->firstOrFail()
)
);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\MerchandiseFormResource;
use App\Domains\Forms\Services\MerchandiseFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class MerchandiseFormController extends Controller
{
public function __construct(protected MerchandiseFormService $merchandiseFormService) {}
public function __invoke(Request $request): MerchandiseFormResource
{
return MerchandiseFormResource::make(
$this->merchandiseFormService->get(
$request->user('sanctum')->tenant()->firstOrFail()
)
);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\SaleFormResource;
use App\Domains\Forms\Services\SaleFormService;
use App\Http\Controllers\Controller;
class SaleFormController extends Controller
{
public function __construct(protected SaleFormService $saleFormService) {}
public function __invoke(): SaleFormResource
{
return SaleFormResource::make($this->saleFormService->get());
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\StaffFormResource;
use App\Domains\Forms\Services\StaffFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class StaffFormController extends Controller
{
public function __construct(protected StaffFormService $staffFormService) {}
public function __invoke(Request $request): StaffFormResource
{
return StaffFormResource::make(
$this->staffFormService->get(
$request->user('sanctum')->tenant()->firstOrFail()
)
);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\TicketFilterFormResource;
use App\Domains\Forms\Services\TicketFilterFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class TicketFilterFormController extends Controller
{
public function __construct(private readonly TicketFilterFormService $formService) {}
public function __invoke(Request $request): TicketFilterFormResource
{
$tenant = $request->user('sanctum')->tenant()->firstOrFail();
return TicketFilterFormResource::make($this->formService->get($tenant));
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\TicketFormResource;
use App\Domains\Forms\Services\TicketFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class TicketFormController extends Controller
{
public function __construct(protected TicketFormService $ticketFormService) {}
public function __invoke(Request $request): TicketFormResource
{
return TicketFormResource::make(
$this->ticketFormService->get(
$request->user('sanctum')->tenant()->firstOrFail()
)
);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Forms\Resources;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EntryFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'event_dates' => $this->resource['event_dates']->map(
fn (EventDate $eventDate): array => [
'id' => $eventDate->id,
'validity_time_id' => $eventDate->validity_time_id,
'validity_time' => ValidityTimeResource::make($eventDate->validityTime),
'date' => $eventDate->date->format('Y-m-d'),
]
)->values(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Domains\Forms\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'social_media' => SocialMediaOptionResource::collection(
$this->resource['social_media']
),
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Domains\Forms\Resources;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
class FoodFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'event_dates' => $this->resource['event_dates']->map(
fn (EventDate $eventDate): array => [
'id' => $eventDate->id,
'validity_time_id' => $eventDate->validity_time_id,
'validity_time' => ValidityTimeResource::make($eventDate->validityTime),
'date' => $eventDate->date->format('Y-m-d'),
]
)->values(),
'schedules' => $this->options($this->resource['schedules']),
'services' => $this->options($this->resource['services']),
];
}
/**
* @param Collection<int, AttributeOption> $options
* @return Collection<int, array{value: string, label: string}>
*/
private function options(Collection $options): Collection
{
return $options->map(fn (AttributeOption $option): array => [
'value' => $option->value,
'label' => $option->label,
])->values();
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Forms\Resources;
use App\Domains\Catalog\Models\AttributeOption;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
class MerchandiseFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'colors' => $this->options($this->resource['colors']),
'sizes' => $this->options($this->resource['sizes']),
];
}
/**
* @param Collection<int, AttributeOption> $options
* @return Collection<int, array{value: string, label: string}>
*/
private function options(Collection $options): Collection
{
return $options->map(fn (AttributeOption $option): array => [
'value' => $option->value,
'label' => $option->label,
])->values();
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Forms\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class SaleFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'statuses' => $this->resource['statuses'],
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Forms\Resources;
use App\Domains\Tenant\Models\SocialMedia;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin SocialMedia */
class SocialMediaOptionResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'code' => $this->code,
'name' => $this->name,
'icon' => $this->icon,
'url' => $this->url,
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Forms\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class StaffFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'categories' => $this->resource['categories']->map(fn ($category) => [
'id' => $category->id,
'nombre' => $category->nombre,
])->values(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Forms\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TicketFilterFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'code' => $this->resource['code'],
'action' => $this->resource['action'],
'method' => $this->resource['method'],
'fields' => $this->resource['fields'],
'columns' => $this->resource['columns'],
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Domains\Forms\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TicketFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'statuses' => $this->resource['statuses'],
'categories' => $this->resource['categories'],
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Collection;
class EntryFormService
{
/** @return array{event_dates: Collection<int, EventDate>} */
public function get(Tenant $tenant): array
{
return [
'event_dates' => $tenant->eventDates()
->whereNull('rescheduled_to_event_date_id')
->whereNull('suspended_at')
->with('validityTime')
->get(),
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Tenant\Models\SocialMedia;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Collection;
class EventFormService
{
/** @return array{social_media: Collection<int, SocialMedia>} */
public function get(Tenant $tenant): array
{
$urls = $tenant->socialMedia()
->pluck('tenant_social_media.url', 'social_media.code');
return [
'social_media' => SocialMedia::query()
->orderBy('id')
->get()
->each(fn (SocialMedia $item) => $item->setAttribute(
'url',
$urls->get($item->code)
)),
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Event\Enums\EventDateStatus;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Collection;
class FoodFormService
{
/**
* @return array{
* event_dates: Collection<int, EventDate>,
* schedules: Collection<int, AttributeOption>,
* services: Collection<int, AttributeOption>
* }
*/
public function get(Tenant $tenant): array
{
$attributes = Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->whereIn('codigo', ['horario', 'servicio'])
->with('options')
->get()
->keyBy('codigo');
return [
'event_dates' => $tenant->eventDates()
->whereNull('rescheduled_to_event_date_id')
->whereNull('suspended_at')
->with('validityTime')
->get()
->filter(fn (EventDate $eventDate): bool => in_array(
$eventDate->status,
[EventDateStatus::Scheduled, EventDateStatus::InProgress],
true,
))
->values(),
'schedules' => $attributes->get('horario')?->options ?? new Collection,
'services' => $attributes->get('servicio')?->options ?? new Collection,
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Collection;
class MerchandiseFormService
{
/**
* @return array{
* colors: Collection<int, AttributeOption>,
* sizes: Collection<int, AttributeOption>
* }
*/
public function get(Tenant $tenant): array
{
$attributes = Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->whereIn('codigo', ['color', 'talle'])
->with('options')
->get()
->keyBy('codigo');
return [
'colors' => $attributes->get('color')?->options ?? new Collection,
'sizes' => $attributes->get('talle')?->options ?? new Collection,
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Purchase\Models\Purchase;
class SaleFormService
{
/** @return array{statuses: list<array{value: string, label: string, real_statuses: list<string>}>} */
public function get(): array
{
return [
'statuses' => array_map(
fn (string $code, array $definition): array => [
'value' => $code,
'label' => $definition['name'],
'real_statuses' => $definition['statuses'],
],
array_keys(Purchase::adminStatuses()),
array_values(Purchase::adminStatuses()),
),
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Catalog\Models\Category;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
class StaffFormService
{
/** @return array{categories: Collection<int, Category>} */
public function get(Tenant $tenant): array
{
return [
'categories' => Category::query()
->whereNull('categoria_id')
->where(function (Builder $query) use ($tenant): void {
$query->where('tenant_code', $tenant->codigo)
->orWhereHas('catalogItems', fn (Builder $items) => $items
->where('tenant_code', $tenant->codigo));
})
->orderBy('nombre')
->get(),
];
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Services\AdminAppTicketColumnService;
class TicketFilterFormService
{
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
public function __construct(
private readonly TicketFormService $ticketFormService,
private readonly AdminAppTicketColumnService $columnService,
) {}
/** @return array<string, mixed> */
public function get(Tenant $tenant): array
{
$fields = $this->commonFields();
if ($tenant->codigo === self::FIESTA_FUTBOL_INFANTIL) {
$fields = [
...$this->fiestaFutbolInfantilFields($tenant),
...$this->commonFields(includeDate: false),
];
}
return [
'code' => 'tickets_filter',
'action' => '/api/v1/adminapp/tenant/tickets',
'method' => 'GET',
'fields' => $fields,
'columns' => $this->columnService->publicColumns($tenant),
];
}
/** @return list<array<string, mixed>> */
private function fiestaFutbolInfantilFields(Tenant $tenant): array
{
$form = $this->ticketFormService->getForFilters($tenant);
return [
[
'name' => 'category',
'query_param' => 'category',
'label' => 'Categoría',
'type' => 'select',
'required' => false,
'default' => null,
'placeholder' => 'Categoría',
'options' => array_map(
fn (array $category): array => [
'value' => $category['value'],
'label' => $category['label'],
'children' => [
[
'field' => 'product',
'disabled' => $category['products'] === [],
'options' => array_map(
fn (array $product): array => [
'value' => $product['value'],
'label' => $product['label'],
'children' => [
[
'field' => 'type',
'disabled' => $product['types'] === [],
'options' => array_map(
fn (array $type): array => [
'value' => $type['value'],
'label' => $type['label'],
'children' => [[
'field' => 'size',
'disabled' => ($type['sizes'] ?? []) === [],
'options' => $type['sizes'] ?? [],
]],
],
$product['types'],
),
],
],
],
$category['products'],
),
],
[
'field' => 'date',
'disabled' => ! in_array($category['value'], ['comidas', 'comida'], true),
'options' => [],
],
],
],
$form['categories'],
),
],
$this->dependentSelect('product', 'Producto', 'category'),
$this->dependentSelect('type', 'Tipo', 'product'),
$this->dependentSelect('size', 'Talle', 'type'),
[
...$this->dependentSelect('date', 'Fecha', 'category'),
'type' => 'date',
],
];
}
/** @return list<array<string, mixed>> */
private function commonFields(bool $includeDate = true): array
{
return [
...($includeDate ? [[
'name' => 'date',
'query_param' => 'date',
'label' => 'Fecha',
'type' => 'date',
'required' => false,
'default' => null,
]] : []),
[
'name' => 'status',
'query_param' => 'status',
'label' => 'Estado',
'type' => 'select',
'required' => false,
'default' => null,
'placeholder' => 'Estado',
'options' => array_values(array_filter(
Ticket::statusOptions(),
fn (array $option): bool => $option['value'] !== Ticket::STATUS_CANCELLED,
)),
],
];
}
/** @return array<string, mixed> */
private function dependentSelect(string $name, string $label, string $dependency): array
{
return [
'name' => $name,
'query_param' => $name,
'label' => $label,
'type' => 'select',
'required' => false,
'default' => null,
'placeholder' => $label,
'depends_on' => $dependency,
'disabled' => true,
'options' => [],
];
}
}

View File

@@ -0,0 +1,396 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Collection;
class TicketFormService
{
private const PRODUCT = 'product';
/**
* @var array<string, array{label: string|null, product: string, type: string|null, order: int}>
*/
private const CATEGORY_PRESENTATIONS = [
'entradas' => [
'label' => null,
'product' => self::PRODUCT,
'type' => null,
'order' => 1,
],
'alojamientos' => [
'label' => 'Camping',
'product' => 'tipo_alojamiento',
'type' => null,
'order' => 2,
],
'camping' => [
'label' => null,
'product' => 'tipo_alojamiento',
'type' => null,
'order' => 2,
],
'comidas' => [
'label' => 'Comida',
'product' => 'event_date',
'type' => 'horario',
'order' => 3,
],
'comida' => [
'label' => null,
'product' => 'event_date',
'type' => 'horario',
'order' => 3,
],
'merchandising' => [
'label' => null,
'product' => self::PRODUCT,
'type' => 'color',
'order' => 4,
],
];
/**
* @var array<string, array{label: string|null, product: string, type: string|null, size: string|null, order: int}>
*/
private const FILTER_CATEGORY_PRESENTATIONS = [
'entradas' => ['label' => null, 'product' => self::PRODUCT, 'type' => null, 'size' => null, 'order' => 1],
'alojamientos' => ['label' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null, 'order' => 2],
'camping' => ['label' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null, 'order' => 2],
'comidas' => ['label' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'size' => null, 'order' => 3],
'comida' => ['label' => null, 'product' => 'horario', 'type' => 'servicio', 'size' => null, 'order' => 3],
'merchandising' => ['label' => null, 'product' => self::PRODUCT, 'type' => 'color', 'size' => 'talle', 'order' => 4],
];
/**
* @return array{
* statuses: list<array{value: string, label: string}>,
* categories: list<array{
* value: string,
* label: string,
* products: list<array{
* value: string,
* label: string,
* types: list<array{value: string, label: string}>
* }>
* }>
* }
*/
public function get(Tenant $tenant): array
{
$items = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('has_tickets', true)
->whereHas('category')
->with($this->relations())
->orderBy('group_order')
->orderBy('nombre')
->get();
return $this->build($items, self::CATEGORY_PRESENTATIONS);
}
/**
* Return active catalog options plus soft-deleted sources still referenced by
* tickets, so historical tickets never become impossible to filter.
*
* @return array{
* statuses: list<array{value: string, label: string}>,
* categories: list<array{
* value: string,
* label: string,
* products: list<array{
* value: string,
* label: string,
* types: list<array{value: string, label: string}>
* }>
* }>
* }
*/
public function getForFilters(Tenant $tenant): array
{
$historicalVariantIds = Ticket::query()
->where('tenant_code', $tenant->codigo)
->whereNotNull('source_variant_id')
->distinct()
->pluck('source_variant_id')
->map(fn ($id): int => (int) $id)
->all();
$historicalCatalogItemIds = Ticket::query()
->where('tenant_code', $tenant->codigo)
->whereNotNull('source_catalog_item_id')
->distinct()
->pluck('source_catalog_item_id')
->map(fn ($id): int => (int) $id)
->merge(
Variant::withTrashed()
->whereKey($historicalVariantIds)
->pluck('catalog_item_id')
->map(fn ($id): int => (int) $id),
)
->unique()
->values()
->all();
$items = CatalogItem::withTrashed()
->where('tenant_code', $tenant->codigo)
->whereHas('category')
->where(function ($query) use ($historicalCatalogItemIds): void {
$query
->where(function ($activeQuery): void {
$activeQuery
->whereNull('catalog_items.deleted_at')
->where('has_tickets', true);
})
->orWhereIn('catalog_items.id', $historicalCatalogItemIds);
})
->with([
'category',
'itemAttributes.attribute.options',
'variants' => fn ($query) => $query
->withTrashed()
->where(function ($variantQuery) use ($historicalVariantIds): void {
$variantQuery
->whereNull('variantes.deleted_at')
->orWhereIn('variantes.id', $historicalVariantIds);
}),
'variants.definitions.itemAttribute.attribute.options',
'variants.eventDates',
'variants.eventDate',
])
->orderBy('group_order')
->orderBy('nombre')
->get();
return $this->build($items, self::FILTER_CATEGORY_PRESENTATIONS, includeSizes: true);
}
/**
* @param Collection<int, CatalogItem> $items
* @return array{
* statuses: list<array{value: string, label: string}>,
* categories: list<array{
* value: string,
* label: string,
* products: list<array{
* value: string,
* label: string,
* types: list<array{value: string, label: string}>
* }>
* }>
* }
*/
private function build(Collection $items, array $presentations, bool $includeSizes = false): array
{
$categories = [];
foreach ($items as $item) {
$sourceCategory = trim((string) $item->category?->nombre);
$categoryValue = mb_strtolower($sourceCategory);
$presentation = $presentations[$categoryValue] ?? [
'label' => null,
'product' => self::PRODUCT,
'type' => null,
'size' => null,
'order' => PHP_INT_MAX,
];
$categories[$categoryValue] ??= [
'value' => $categoryValue,
'label' => $presentation['label'] ?? $sourceCategory,
'order' => $presentation['order'],
'products' => [],
];
foreach ($this->products(
$item,
$presentation['product'],
$presentation['type'],
$presentation['size'] ?? null,
) as $product) {
$productValue = $product['value'];
$existingProduct = $categories[$categoryValue]['products'][$productValue] ?? [
'value' => $productValue,
'label' => $product['label'],
'types' => [],
'sizes' => [],
];
foreach ($product['types'] as $type) {
$existingProduct['types'][$type['value']] = $type;
}
foreach ($product['sizes'] as $size) {
$existingProduct['sizes'][$size['value']] = $size;
}
$categories[$categoryValue]['products'][$productValue] = $existingProduct;
}
}
uasort($categories, fn (array $left, array $right): int => $left['order'] <=> $right['order']
?: $left['label'] <=> $right['label']);
return [
'statuses' => Ticket::statusOptions(),
'categories' => array_values(array_map(
fn (array $category): array => [
'value' => $category['value'],
'label' => $category['label'],
'products' => array_values(array_map(
fn (array $product): array => [
'value' => $product['value'],
'label' => $product['label'],
'types' => array_values($product['types']),
...($includeSizes ? ['sizes' => array_values($product['sizes'])] : []),
],
$category['products'],
)),
],
$categories,
)),
];
}
/** @return list<string> */
private function relations(): array
{
return [
'category',
'itemAttributes.attribute.options',
'variants.definitions.itemAttribute.attribute.options',
'variants.eventDates',
'variants.eventDate',
];
}
/**
* @return list<array{
* value: string,
* label: string,
* types: list<array{value: string, label: string}>,
* sizes: list<array{value: string, label: string}>
* }>
*/
private function products(CatalogItem $item, string $productCode, ?string $typeCode, ?string $sizeCode): array
{
if ($productCode === self::PRODUCT) {
return [[
'value' => $item->slug,
'label' => $item->nombre,
'types' => $this->types($item, $typeCode, $sizeCode),
'sizes' => $this->types($item, $sizeCode),
]];
}
$products = [];
foreach ($item->variants as $variant) {
foreach ($this->variantOptions($variant, $productCode) as $productOption) {
$productValue = $productOption['value'];
$products[$productValue] ??= [
'value' => $productValue,
'label' => $this->optionLabel($productOption['label'], $productCode),
'types' => [],
'sizes' => [],
];
foreach ($this->variantOptions($variant, $typeCode) as $typeOption) {
$this->mergeTypeOption(
$products[$productValue]['types'],
$typeOption,
$variant,
$sizeCode,
);
}
foreach ($this->variantOptions($variant, $sizeCode) as $sizeOption) {
$products[$productValue]['sizes'][$sizeOption['value']] = $sizeOption;
}
}
}
return array_values(array_map(
fn (array $product): array => [
'value' => $product['value'],
'label' => $product['label'],
'types' => array_values($product['types']),
'sizes' => array_values($product['sizes']),
],
$products,
));
}
/** @return list<array<string, mixed>> */
private function types(CatalogItem $item, ?string $typeCode, ?string $sizeCode = null): array
{
$types = [];
foreach ($item->variants as $variant) {
foreach ($this->variantOptions($variant, $typeCode) as $typeOption) {
$this->mergeTypeOption($types, $typeOption, $variant, $sizeCode);
}
}
return array_values(array_map(function (array $type) use ($sizeCode): array {
if ($sizeCode !== null) {
$type['sizes'] = array_values($type['sizes']);
}
return $type;
}, $types));
}
/**
* @param array<string, array<string, mixed>> $types
* @param array{value: string, label: string} $typeOption
*/
private function mergeTypeOption(
array &$types,
array $typeOption,
Variant $variant,
?string $sizeCode,
): void {
$typeValue = $typeOption['value'];
$types[$typeValue] ??= [
...$typeOption,
...($sizeCode !== null ? ['sizes' => []] : []),
];
foreach ($this->variantOptions($variant, $sizeCode) as $sizeOption) {
$types[$typeValue]['sizes'][$sizeOption['value']] = $sizeOption;
}
}
/** @return list<array{value: string, label: string}> */
private function variantOptions(Variant $variant, ?string $attributeCode): array
{
if ($attributeCode === null) {
return [];
}
$selection = $variant->selectionOptions($variant->catalogItem->itemAttributes)
->get($attributeCode);
if ($selection === null) {
return [];
}
return array_is_list($selection) ? $selection : [$selection];
}
private function optionLabel(string $label, string $attributeCode): string
{
if ($attributeCode !== 'event_date') {
return $label;
}
[$day, $month] = array_pad(explode('/', $label), 2, null);
return $day !== null && $month !== null ? "{$day}/{$month}" : $label;
}
}

View File

@@ -0,0 +1,28 @@
# Dominio Forms
## Propósito
Provee catálogos y opciones auxiliares para construir formularios del panel administrativo. Es un dominio de lectura que compone datos pertenecientes a otros dominios.
## Formularios disponibles
- `EventFormService`: devuelve redes sociales disponibles y las URL configuradas para el tenant.
- `SaleFormService`: expone los estados admitidos para compras con sus etiquetas de presentación.
- `StaffFormService`: lista categorías raíz que pueden asignarse al personal del tenant.
- `TicketFormService`: expone estados y opciones anidadas de categoría, producto y tipo para los tickets de Fiesta Fútbol Infantil.
Cada servicio tiene un controlador invocable y un `JsonResource` específico. `SocialMediaOptionResource` representa las opciones de redes sociales.
## Endpoints
Bajo `/v1/adminapp/forms`, con `auth:sanctum` y `adminapp.tenant`:
- `GET /event`.
- `GET /sale`.
- `GET /staff`.
- `GET /fiesta-futbol-infantil/ticket`: estados y jerarquía categoría → producto → tipo para filtros de tickets.
- `GET /fiesta-futbol-infantil/merchandise`: opciones de color y talle del tenant para merchandising.
## Dependencias
Compone datos de `Tenant`, `Purchase` y `Catalog`. No debe duplicar reglas de negocio: las listas y estados canónicos siguen perteneciendo a sus dominios de origen.

View File

@@ -0,0 +1,38 @@
<?php
use App\Domains\Forms\Controllers\AdminApp\EntryFormController;
use App\Domains\Forms\Controllers\AdminApp\EventFormController;
use App\Domains\Forms\Controllers\AdminApp\FoodFormController;
use App\Domains\Forms\Controllers\AdminApp\MerchandiseFormController;
use App\Domains\Forms\Controllers\AdminApp\SaleFormController;
use App\Domains\Forms\Controllers\AdminApp\StaffFormController;
use App\Domains\Forms\Controllers\AdminApp\TicketFilterFormController;
use App\Domains\Forms\Controllers\AdminApp\TicketFormController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/forms')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('event', EventFormController::class);
Route::get('sale', SaleFormController::class);
Route::get('staff', StaffFormController::class);
Route::get('tickets-filter', TicketFilterFormController::class)
->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.forms.tickets-filter');
Route::get(
'fiesta-futbol-infantil/ticket',
TicketFormController::class
);
Route::get(
'fiesta-futbol-infantil/entry',
EntryFormController::class
);
Route::get(
'fiesta-futbol-infantil/merchandise',
MerchandiseFormController::class
);
Route::get(
'fiesta-futbol-infantil/food',
FoodFormController::class
);
});

View File

@@ -0,0 +1,3 @@
<?php
require __DIR__.'/adminapp.php';

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Domains\Integration\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Encryption\Encrypter;
use Exception;
class EncryptedIntegrationData implements CastsAttributes
{
protected function getEncrypter(): Encrypter
{
$secret = config('services.integrations.secret');
if (empty($secret)) {
throw new Exception('The integrations secret is not configured.');
}
// Laravel encrypter requires a key of exact length. Typically 32 bytes for AES-256-CBC.
// If the secret is base64 encoded like the APP_KEY:
if (str_starts_with($secret, 'base64:')) {
$key = base64_decode(substr($secret, 7));
} else {
// Otherwise, we hash it to ensure 32 bytes for AES-256-CBC.
$key = hash('sha256', $secret, true);
}
return new Encrypter($key, config('app.cipher', 'AES-256-CBC'));
}
/**
* Cast the given value.
*
* @param array<string, mixed> $attributes
*/
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
{
if ($value === null) {
return null;
}
try {
$decrypted = $this->getEncrypter()->decryptString($value);
return json_decode($decrypted, true);
} catch (Exception $e) {
// Return null or throw depending on how strict we want to be.
return null;
}
}
/**
* Prepare the given value for storage.
*
* @param array<string, mixed> $attributes
*/
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
{
if ($value === null) {
return null;
}
$json = json_encode($value);
return $this->getEncrypter()->encryptString($json);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domains\Integration\Controllers;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Requests\ConfigureIntegrationRequest;
use App\Domains\Integration\Resources\IntegrationAssociationResource;
use App\Domains\Integration\Services\IntegrationAssociationService;
use App\Domains\Tenant\Models\AdminWebsiteType;
use App\Http\Controllers\Controller;
class AdminWebsiteTypeIntegrationController extends Controller
{
public function __construct(private readonly IntegrationAssociationService $service) {}
public function index(AdminWebsiteType $adminWebsiteType)
{
return IntegrationAssociationResource::collection($adminWebsiteType->integrations()->with('integrationInstance')->get());
}
public function show(AdminWebsiteType $adminWebsiteType, string $integrationCode)
{
return new IntegrationAssociationResource($adminWebsiteType->integrations()->with('integrationInstance')->where('integration_code', $integrationCode)->firstOrFail());
}
public function store(ConfigureIntegrationRequest $request, AdminWebsiteType $adminWebsiteType, string $integrationCode)
{
$integration = Integration::query()->where('integration_code', $integrationCode)->firstOrFail();
return new IntegrationAssociationResource($this->service->configure(
$adminWebsiteType,
$integration,
$request->validated('integration_data'),
));
}
public function destroy(AdminWebsiteType $adminWebsiteType, string $integrationCode)
{
$this->service->detach($adminWebsiteType, $integrationCode);
return response()->noContent();
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Domains\Integration\Controllers;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Requests\ConfigureIntegrationRequest;
use App\Domains\Integration\Services\ClientIntegrationService;
use App\Domains\Integration\Services\IntegrationAssociationService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class ClientIntegrationController extends Controller
{
public function __construct(
private readonly ClientIntegrationService $clientIntegrationService,
) {}
public function index(Client $client): JsonResponse
{
return response()->json($this->clientIntegrationService->getAllForClient($client));
}
public function show(Client $client, string $integrationCode): JsonResponse
{
$integration = $this->clientIntegrationService->getClientIntegration($client, $integrationCode);
if (! $integration) {
return response()->json([
'code' => 'integration.not_configured',
'message' => __('api.integration.not_configured'),
], 404);
}
return response()->json($integration);
}
public function store(
ConfigureIntegrationRequest $request,
Client $client,
string $integrationCode,
): JsonResponse {
$integration = Integration::query()
->where('integration_code', $integrationCode)
->firstOrFail();
try {
$this->clientIntegrationService->updateOrCreateIntegration(
$client,
$integration,
$request->input('integration_data', []),
);
return response()->json([
'code' => 'integration.configured',
'message' => __('api.integration.configured'),
]);
} catch (\Exception $exception) {
return response()->json([
'code' => 'integration.validation_failed',
'message' => __('api.integration.validation_failed', ['error' => $exception->getMessage()]),
], 400);
}
}
public function destroy(Client $client, string $integrationCode, IntegrationAssociationService $service)
{
$service->detach($client, $integrationCode);
return response()->noContent();
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domains\Integration\Controllers;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Requests\StoreIntegrationRequest;
use App\Domains\Integration\Requests\UpdateIntegrationRequest;
use Illuminate\Routing\Controller;
class IntegrationController extends Controller
{
public function index()
{
return response()->json(Integration::all());
}
public function store(StoreIntegrationRequest $request)
{
$integration = Integration::create($request->validated());
return response()->json($integration, 201);
}
public function show(Integration $integration)
{
return response()->json($integration);
}
public function update(UpdateIntegrationRequest $request, Integration $integration)
{
$integration->update($request->validated());
return response()->json($integration->fresh());
}
public function destroy(Integration $integration)
{
abort_if($integration->instances()->exists(), 409, 'Delete the integration instances first.');
$integration->delete();
return response()->noContent();
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Integration\Controllers;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Requests\TelepagosWebhookRequest;
use App\Domains\Integration\Services\TelepagosWebhookService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class TelepagosWebhookController extends Controller
{
/**
* Handle the incoming Telepagos webhook.
*/
public function handle(TelepagosWebhookRequest $request, Client $client, TelepagosWebhookService $service): JsonResponse
{
try {
$cashinId = $request->validated('id');
$service->handleWebhook($client, $cashinId);
return response()->json(['status' => 'success']);
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'code' => 'integration.webhook_failed',
'message' => __('api.integration.webhook_failed'),
], 500);
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Integration\Models;
use App\Domains\Tenant\Models\AdminWebsiteType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AdminWebsiteTypeIntegration extends Model
{
protected $table = 'admin_website_type_integrations';
protected $fillable = ['admin_website_type_code', 'integration_code', 'integration_instance_id'];
/** @return BelongsTo<AdminWebsiteType, $this> */
public function adminWebsiteType(): BelongsTo
{
return $this->belongsTo(AdminWebsiteType::class, 'admin_website_type_code', 'codigo');
}
/** @return BelongsTo<Integration, $this> */
public function integration(): BelongsTo
{
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
}
/** @return BelongsTo<IntegrationInstance, $this> */
public function integrationInstance(): BelongsTo
{
return $this->belongsTo(IntegrationInstance::class);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Domains\Integration\Models;
use App\Domains\Client\Models\Client;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ClientIntegration extends Model
{
protected $fillable = [
'client_id',
'integration_code',
'integration_instance_id',
];
/** @return BelongsTo<Client, $this> */
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
/** @return BelongsTo<Integration, $this> */
public function integration(): BelongsTo
{
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
}
/** @return BelongsTo<IntegrationInstance, $this> */
public function integrationInstance(): BelongsTo
{
return $this->belongsTo(IntegrationInstance::class);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Domains\Integration\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Integration extends Model
{
protected $table = 'integrations';
protected $fillable = [
'integration_code',
'name',
'url',
'integration_data_schema',
'requires_configuration',
];
protected $casts = [
'integration_data_schema' => 'array',
'requires_configuration' => 'boolean',
];
/** @return HasMany<IntegrationInstance, $this> */
public function instances(): HasMany
{
return $this->hasMany(IntegrationInstance::class, 'integration_code', 'integration_code');
}
/** @return HasMany<ClientIntegration, $this> */
public function clientIntegrations(): HasMany
{
return $this->hasMany(ClientIntegration::class, 'integration_code', 'integration_code');
}
/** @return HasMany<AdminWebsiteTypeIntegration, $this> */
public function websiteTypeIntegrations(): HasMany
{
return $this->hasMany(AdminWebsiteTypeIntegration::class, 'integration_code', 'integration_code');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Domains\Integration\Models;
use App\Domains\Integration\Casts\EncryptedIntegrationData;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class IntegrationInstance extends Model
{
// The ciphertext changes whenever credentials are saved, so old tokens cannot be reused.
public function tokenCacheKey(): string
{
return 'integration_token:instance:'.$this->id.':'.hash('sha256', (string) $this->getRawOriginal('integration_data'));
}
protected $fillable = ['integration_code', 'name', 'integration_data'];
protected $hidden = ['integration_data'];
protected $casts = ['integration_data' => EncryptedIntegrationData::class];
/** @return BelongsTo<Integration, $this> */
public function integration(): BelongsTo
{
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
}
/** @return HasMany<ClientIntegration, $this> */
public function clientIntegrations(): HasMany
{
return $this->hasMany(ClientIntegration::class);
}
/** @return HasMany<AdminWebsiteTypeIntegration, $this> */
public function websiteTypeIntegrations(): HasMany
{
return $this->hasMany(AdminWebsiteTypeIntegration::class);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Domains\Integration\Policies;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
class IntegrationPolicy
{
public function manage(User $user): bool
{
return $user->rol_codigo === RoleCode::Admin->value;
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Domains\Integration\Requests;
use App\Domains\Integration\Models\Integration;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
class ConfigureIntegrationRequest extends FormRequest
{
protected ?Integration $integrationModel = null;
public function authorize(): bool
{
return $this->user()?->can('manage', Integration::class) ?? false;
}
protected function prepareForValidation(): void
{
$this->integrationModel = Integration::query()
->where('integration_code', $this->route('integration_code'))
->first();
if (! $this->integrationModel) {
throw ValidationException::withMessages([
'integration_code' => __('api.integration.not_configured'),
]);
}
}
public function rules(): array
{
$rules = ['integration_data' => ['present', 'array']];
foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) {
$rules['integration_data.'.$field] = $rule;
}
return $rules;
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Integration\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreIntegrationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'integration_code' => ['required', 'string', 'unique:integrations,integration_code'],
'name' => ['required', 'string', 'max:255'],
'url' => ['nullable', 'url', 'max:255'],
'integration_data_schema' => ['nullable', 'array'],
'requires_configuration' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Domains\Integration\Requests;
use Illuminate\Foundation\Http\FormRequest;
class TelepagosWebhookRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
*/
public function rules(): array
{
return [
'id' => ['required', 'string'],
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Domains\Integration\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateIntegrationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$integration = $this->route('integration');
return [
'name' => ['sometimes', 'required', 'string', 'max:255'],
'url' => ['nullable', 'url', 'max:255'],
'integration_data_schema' => ['nullable', 'array'],
'requires_configuration' => ['sometimes', 'boolean'],
'integration_code' => ['sometimes', 'required', 'string', Rule::in([$integration->integration_code])],
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Integration\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class IntegrationAssociationResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'client_id' => $this->when(isset($this->client_id), $this->client_id),
'admin_website_type_code' => $this->when(isset($this->admin_website_type_code), $this->admin_website_type_code),
'integration_code' => $this->integration_code,
'integration_instance_id' => $this->integration_instance_id,
'integration_instance' => new IntegrationInstanceResource($this->whenLoaded('integrationInstance')),
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Integration\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class IntegrationInstanceResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'integration_code' => $this->integration_code,
'name' => $this->name,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@@ -0,0 +1,180 @@
<?php
namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Models\ClientIntegration;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\IntegrationInstance;
use App\Domains\Integration\Models\AdminWebsiteTypeIntegration;
use App\Domains\Tenant\Models\Tenant;
use Exception;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
abstract class BaseIntegrationService
{
/**
* The unique code of the integration.
*/
protected string $integrationCode;
/**
* The current tenant code.
*/
protected string $tenantCode;
protected ?Tenant $tenant = null;
protected ?Client $clientContext = null;
/**
* The integration model instance.
*/
protected ?Integration $integration = null;
/**
* The effective integration instance.
*/
protected ?IntegrationInstance $integrationInstance = null;
/**
* Set the integration code.
*
* @return $this
*/
public function setIntegrationCode(string $integrationCode): self
{
$this->integrationCode = $integrationCode;
return $this;
}
/**
* Get the integration code.
*/
public function getIntegrationCode(): string
{
return $this->integrationCode;
}
/**
* Set the tenant code and load the integration models.
*
* @return $this
*
* @throws Exception
*/
public function forTenant(string $tenantCode): self
{
$this->tenantCode = $tenantCode;
$this->tenant = Tenant::query()->with('client')->where('codigo', $tenantCode)->firstOrFail();
$this->clientContext = $this->tenant->client;
$this->loadIntegration();
return $this;
}
public function forClient(Client|string $client): self
{
$this->clientContext = $client instanceof Client
? $client
: Client::query()->where('code', $client)->firstOrFail();
$this->tenant = null;
$this->loadIntegration();
return $this;
}
/**
* Load the integration definition and its effective instance configuration.
*
* @throws Exception
*/
protected function loadIntegration(): void
{
if (empty($this->integrationCode)) {
throw new Exception('Integration code is not set.');
}
$this->integrationInstance = null;
$this->integration = Integration::where('integration_code', $this->integrationCode)->first();
if (! $this->integration) {
throw new Exception("Integration with code '{$this->integrationCode}' not found.");
}
if (! $this->clientContext) {
throw new Exception('Client context is not set.');
}
$this->integrationInstance = ClientIntegration::with('integrationInstance')->where('client_id', $this->clientContext->id)
->where('integration_code', $this->integrationCode)
->first()?->integrationInstance;
if (! $this->integrationInstance && $this->tenant?->admin_website_type_code) {
$this->integrationInstance = AdminWebsiteTypeIntegration::with('integrationInstance')
->where('admin_website_type_code', $this->tenant->admin_website_type_code)
->where('integration_code', $this->integrationCode)
->first()?->integrationInstance;
}
if (! $this->integrationInstance && $this->integration->requires_configuration) {
throw new Exception("Client '{$this->clientContext->code}' does not have integration '{$this->integrationCode}' configured.");
}
}
/**
* Build the request URL.
*
* @throws Exception
*/
public function getUrl(string $path = ''): string
{
if (! $this->integration) {
throw new Exception('Integration is not loaded. Call forTenant() or forClient() first.');
}
$baseUrl = rtrim($this->integration->url, '/');
$path = ltrim($path, '/');
return $path !== '' ? "{$baseUrl}/{$path}" : $baseUrl;
}
/**
* Get an integration setting from the effective instance configuration.
*/
protected function getIntegrationSetting(string $key, mixed $default = null): mixed
{
if (! $this->integrationInstance || ! $this->integrationInstance->integration_data) {
return $default;
}
return $this->integrationInstance->integration_data[$key] ?? $default;
}
/**
* Get a pre-configured HTTP client builder.
*
* @throws Exception
*/
public function client(): PendingRequest
{
return Http::baseUrl($this->getUrl())
->withHeaders($this->getHeaders());
}
/**
* Get the headers for the integration.
*/
abstract public function getHeaders(): array;
/**
* Hook called after the integration is configured for the client.
* Can be used to validate credentials or perform initial setups.
* Throw an Exception on failure.
*/
public function onSetup(): void
{
// Override in child classes if needed
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Models\ClientIntegration;
use App\Domains\Integration\Models\Integration;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class ClientIntegrationService
{
public function getClientIntegration(Client $client, string $integrationCode): ?ClientIntegration
{
return $client->integrations()
->with(['integration', 'integrationInstance'])
->where('integration_code', $integrationCode)
->first();
}
/** @return Collection<int, ClientIntegration> */
public function getAllForClient(Client $client): Collection
{
return $client->integrations()->with(['integration', 'integrationInstance'])->get();
}
public function updateOrCreateIntegration(
Client $client,
Integration $integration,
array $data,
): ClientIntegration {
return DB::transaction(function () use ($client, $integration, $data): ClientIntegration {
$clientIntegration = app(IntegrationAssociationService::class)->configure($client, $integration, $data);
$service = $this->resolveService($integration->integration_code);
$service?->forClient($client)->onSetup();
return $clientIntegration;
});
}
protected function resolveService(string $integrationCode): ?BaseIntegrationService
{
return match ($integrationCode) {
'email' => new MailService,
'telepagos', 'telepagos_homo' => new TelepagosIntegrationService($integrationCode),
default => null,
};
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Models\ClientIntegration;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\IntegrationInstance;
use App\Domains\Integration\Models\AdminWebsiteTypeIntegration;
use App\Domains\Tenant\Models\AdminWebsiteType;
use Illuminate\Support\Facades\DB;
class IntegrationAssociationService
{
public function configure(Client|AdminWebsiteType $owner, Integration $integration, array $integrationData): ClientIntegration|AdminWebsiteTypeIntegration
{
return DB::transaction(function () use ($owner, $integration, $integrationData): ClientIntegration|AdminWebsiteTypeIntegration {
$instance = IntegrationInstance::create([
'integration_code' => $integration->integration_code,
'name' => $integration->name.' / '.$this->ownerName($owner),
'integration_data' => $integrationData,
]);
return $this->associate($owner, $integration->integration_code, $instance);
});
}
public function associate(Client|AdminWebsiteType $owner, string $code, IntegrationInstance $instance): ClientIntegration|AdminWebsiteTypeIntegration
{
return DB::transaction(function () use ($owner, $code, $instance): ClientIntegration|AdminWebsiteTypeIntegration {
$association = $owner->integrations()
->where('integration_code', $code)
->lockForUpdate()
->first();
$previousInstanceId = $association?->integration_instance_id;
$instanceIds = array_values(array_unique(array_filter([
$previousInstanceId,
$instance->id,
])));
sort($instanceIds);
$instances = IntegrationInstance::query()
->whereKey($instanceIds)
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
$instance = $instances->get($instance->id) ?? IntegrationInstance::query()->findOrFail($instance->id);
abort_unless($instance->integration_code === $code, 422, 'The instance belongs to another integration.');
$association = $owner->integrations()->updateOrCreate(
['integration_code' => $code],
['integration_instance_id' => $instance->id],
);
if ($previousInstanceId && $previousInstanceId !== $instance->id) {
$this->deleteIfUnused($previousInstanceId);
}
return $association->load('integrationInstance');
});
}
public function detach(Client|AdminWebsiteType $owner, string $code): void
{
DB::transaction(function () use ($owner, $code): void {
$association = $owner->integrations()
->where('integration_code', $code)
->lockForUpdate()
->first();
if (! $association) {
return;
}
$instanceId = $association->integration_instance_id;
$association->delete();
$this->deleteIfUnused($instanceId);
});
}
private function deleteIfUnused(int $instanceId): void
{
$instance = IntegrationInstance::query()->lockForUpdate()->find($instanceId);
if ($instance
&& ! $instance->clientIntegrations()->exists()
&& ! $instance->websiteTypeIntegrations()->exists()) {
$instance->delete();
}
}
private function ownerName(Client|AdminWebsiteType $owner): string
{
return $owner instanceof Client ? $owner->name : $owner->nombre;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Domains\Integration\Services;
use App\Domains\Integration\Models\IntegrationInstance;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class IntegrationInstanceService
{
public function create(array $data): IntegrationInstance
{
return IntegrationInstance::create($data);
}
public function update(IntegrationInstance $instance, array $data): IntegrationInstance
{
return DB::transaction(function () use ($instance, $data): IntegrationInstance {
$instance = IntegrationInstance::query()->lockForUpdate()->findOrFail($instance->id);
$cacheKey = $instance->tokenCacheKey();
$instance->update($data);
if (array_key_exists('integration_data', $data)) {
DB::afterCommit(fn () => Cache::forget($cacheKey));
}
return $instance;
});
}
public function delete(IntegrationInstance $instance): void
{
DB::transaction(function () use ($instance): void {
$instance = IntegrationInstance::query()->lockForUpdate()->findOrFail($instance->id);
abort_if($instance->clientIntegrations()->exists() || $instance->websiteTypeIntegrations()->exists(), 409, 'Unlink the instance before deleting it.');
$cacheKey = $instance->tokenCacheKey();
$instance->delete();
DB::afterCommit(fn () => Cache::forget($cacheKey));
});
}
}

View File

@@ -0,0 +1,226 @@
<?php
namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\AdminWebsiteType;
use Exception;
use Illuminate\Contracts\Mail\Factory as MailFactory;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\MailManager;
use Illuminate\Support\Facades\Blade;
use InvalidArgumentException;
class MailService extends BaseIntegrationService
{
private const REQUIRED_SMTP_FIELDS = [
'MAIL_HOST',
'MAIL_PORT',
'MAIL_USERNAME',
'MAIL_PASSWORD',
'MAIL_FROM_ADDRESS',
];
protected string $integrationCode = 'email';
private readonly MailFactory $mailFactory;
private ?Mailer $mailer = null;
private bool $usesInstanceMailer = false;
public function __construct(?MailFactory $mailFactory = null)
{
$this->mailFactory = $mailFactory ?? app(MailFactory::class);
}
public function forTenant(string $tenantCode): self
{
parent::forTenant($tenantCode);
if ($this->integrationInstance) {
$this->mailer = $this->resolveMailer();
$this->usesInstanceMailer = true;
} else {
$this->mailer = $this->mailFactory->mailer();
$this->usesInstanceMailer = false;
}
return $this;
}
public function forClient(Client|string $client): self
{
parent::forClient($client);
$this->tenant = $this->clientContext?->tenants()->first();
if ($this->integrationInstance) {
$this->mailer = $this->resolveMailer();
$this->usesInstanceMailer = true;
} else {
$this->mailer = $this->mailFactory->mailer();
$this->usesInstanceMailer = false;
}
return $this;
}
public function getHeaders(): array
{
return [];
}
/**
* @param array<int, array{data: string, name: string, mime: string}> $attachments
*/
public function send(
string|array $recipient,
string $subject,
string $content,
Tenant|AdminWebsiteType|null $brand = null,
array $attachments = [],
): void {
if (! $this->mailer || ! $this->tenant) {
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
}
$brand ??= $this->tenant;
$branding = $this->brandingFor($brand);
$html = Blade::render(
<<<'BLADE'
<x-mail.branded-layout :branding="$branding" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
{!! $content !!}
</x-mail.branded-layout>
BLADE,
[
'branding' => $branding,
'headerLogoUrl' => $brand instanceof AdminWebsiteType
? $brand->siteLogo?->getTemporaryUrl(1440)
: $brand->headerLogo?->getTemporaryUrl(1440),
'footerLogoUrl' => $brand->footerLogo?->getTemporaryUrl(1440),
'content' => $content,
],
);
$mail = (new Mailable)
->subject($subject)
->html($html);
foreach ($attachments as $attachment) {
$mail->attachData(
$attachment['data'],
$attachment['name'],
['mime' => $attachment['mime']],
);
}
$this->mailer->to($recipient)->send($mail);
}
public function mailerName(): string
{
return $this->usesInstanceMailer
? 'integration-smtp'
: (string) config('mail.default');
}
/** @return array{name: string, primary_color: string, body_color: string, background_color: string, surface_color: string, header_bg_color: string, footer_bg_color: string} */
private function brandingFor(Tenant|AdminWebsiteType $brand): array
{
if ($brand instanceof AdminWebsiteType) {
$brand->loadMissing(['siteLogo', 'footerLogo']);
return [
'name' => $brand->nombre,
'primary_color' => $brand->primary_color ?? '#FF7006',
'body_color' => $brand->body_color ?? '#666666',
'background_color' => $brand->background_color ?? '#f8f8f8',
'surface_color' => $brand->surface_color ?? '#ffffff',
'header_bg_color' => $brand->surface_color ?? '#ffffff',
'footer_bg_color' => $brand->login_header_footer_color ?? '#838383',
];
}
$brand->loadMissing(['headerLogo', 'footerLogo']);
return [
'name' => $brand->nombre,
'primary_color' => $brand->primary_color ?? '#6376f3',
'body_color' => '#334155',
'background_color' => '#f1f5f9',
'surface_color' => '#ffffff',
'header_bg_color' => $brand->header_bg_color ?? '#ffffff',
'footer_bg_color' => $brand->footer_bg_color ?? '#334155',
];
}
public function onSetup(): void
{
if (! $this->mailer || ! $this->clientContext) {
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
}
$recipient = $this->getIntegrationSetting('MAIL_FROM_ADDRESS');
if (! is_string($recipient) || $recipient === '') {
throw new InvalidArgumentException('Falta MAIL_FROM_ADDRESS en la configuración SMTP del cliente.');
}
$subject = 'Configuración de correo validada';
$content = '<h1 style="margin: 0 0 20px;">Configuración de correo validada</h1>'
.'<p>La integración SMTP de '.e($this->clientContext->name).' fue configurada correctamente.</p>'
.'<p style="color: #64748b; font-size: 13px;">Este mensaje fue enviado automáticamente para validar las credenciales de correo.</p>';
if ($this->tenant) {
$this->send($recipient, $subject, $content);
return;
}
$this->mailer->to($recipient)->send(
(new Mailable)->subject($subject)->html($content)
);
}
private function resolveMailer(): Mailer
{
$data = $this->integrationInstance?->integration_data;
if (! is_array($data)) {
throw new InvalidArgumentException('La configuración SMTP del cliente no es válida.');
}
foreach (self::REQUIRED_SMTP_FIELDS as $field) {
if (! array_key_exists($field, $data) || $data[$field] === null || $data[$field] === '') {
throw new InvalidArgumentException("Falta {$field} en la configuración SMTP del cliente.");
}
}
// MailFake implements MailFactory but cannot build transports.
if (! $this->mailFactory instanceof MailManager) {
return $this->mailFactory->mailer();
}
$mailer = $this->mailFactory->build([
'name' => 'integration-smtp-'.$this->integrationInstance?->id,
'transport' => 'smtp',
'scheme' => $data['MAIL_SCHEME'] ?? null,
'host' => $data['MAIL_HOST'],
'port' => (int) $data['MAIL_PORT'],
'username' => $data['MAIL_USERNAME'],
'password' => $data['MAIL_PASSWORD'],
'timeout' => isset($data['MAIL_TIMEOUT']) ? (int) $data['MAIL_TIMEOUT'] : null,
'local_domain' => $data['MAIL_EHLO_DOMAIN'] ?? null,
]);
$mailer->alwaysFrom(
$data['MAIL_FROM_ADDRESS'],
$data['MAIL_FROM_NAME'] ?? $this->clientContext?->name,
);
return $mailer;
}
}

View File

@@ -0,0 +1,241 @@
<?php
namespace App\Domains\Integration\Services;
use Carbon\Carbon;
use Exception;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TelepagosIntegrationService extends BaseIntegrationService
{
/**
* TelepagosIntegrationService constructor.
*/
public function __construct(string $integrationCode = 'telepagos')
{
// Force homologation code if not in production and using default
if ($integrationCode === 'telepagos' && ! app()->environment('production')) {
$integrationCode = 'telepagos_homo';
}
$this->integrationCode = $integrationCode;
}
/**
* Get the headers for Telepagos integration.
*
* @throws Exception
*/
public function getHeaders(): array
{
return [
'Authorization' => 'Bearer '.$this->getToken(),
'Content-Type' => 'application/json',
'Accept' => 'application/json',
];
}
/**
* Get a valid token, either from cache or by performing a login.
*
* @throws Exception
*/
public function getToken(): string
{
if (! $this->integrationInstance) {
throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.');
}
$cacheKey = $this->integrationInstance->tokenCacheKey();
$token = Cache::get($cacheKey);
if ($token) {
return $token;
}
return $this->login();
}
/**
* Authenticate with Telepagos and cache the returned token.
*
* @throws Exception
*/
public function login(): string
{
$username = $this->getIntegrationSetting('username');
$password = $this->getIntegrationSetting('password');
if (empty($username) || empty($password)) {
throw new Exception('Missing username or password in Telepagos integration settings.');
}
$url = $this->getUrl('/v2/auth/token');
$response = Http::post($url, [
'username' => $username,
'password' => $password,
]);
$data = $this->handleResponse($response, 'authentication');
$token = $data['token'] ?? null;
$expiresAtStr = $data['expires_at'] ?? null;
if (! $token || ! $expiresAtStr) {
throw new Exception('Telepagos authentication response is missing token or expires_at.');
}
$expiresAt = Carbon::parse($expiresAtStr);
// Calculate TTL and subtract a buffer of 60 seconds
$ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60);
$cacheKey = $this->integrationInstance->tokenCacheKey();
Cache::put($cacheKey, $token, $ttlSeconds);
return $token;
}
/**
* Send a request to Telepagos, handling 401 Unauthorized for token refresh.
*/
protected function sendRequest(string $method, string $endpoint, array $data = []): Response
{
$response = $this->client()->$method($endpoint, $data);
if ($response->status() === 401) {
Log::channel('telepagos')->info('Telepagos request returned 401; refreshing token and retrying.', [
'method' => strtoupper($method),
'endpoint' => $endpoint,
'client_id' => $this->clientContext?->id,
'integration_code' => $this->integrationCode,
]);
$this->clearToken();
$response = $this->client()->$method($endpoint, $data);
}
return $response;
}
/**
* Generate a QR code for cash-in.
*
* @throws Exception
*/
public function generateQr(float $amount, string $concept, string $description): array
{
$payload = [
'amount' => $amount,
'concept' => $concept,
'description' => $description,
];
$response = $this->sendRequest('post', '/v2/payment/cashin/qr/generate', $payload);
return $this->handleResponse($response, 'QR generation', $payload);
}
/**
* Get the details of a cash-in payment.
*
* @param int $cashinId
*
* @throws Exception
*/
public function getCashinDetails(string $cashinId): array
{
$response = $this->sendRequest('get', "/v2/payment/cashin/{$cashinId}");
return $this->handleResponse($response, 'get cash-in details', [
'cashin_id' => $cashinId,
]);
}
/**
* Get the account info.
*
* @throws Exception
*/
public function getAccountInfo(): array
{
$response = $this->sendRequest('get', '/v2/account/info');
return $this->handleResponse($response, 'get account info');
}
/**
* Handle the Telepagos API response, logging any failures and throwing Exceptions.
*
* @throws Exception
*/
protected function handleResponse(Response $response, string $actionDescription, array $context = []): array
{
if ($response->failed() || $response->json('status') !== 'ok') {
$errorMessage = $response->json('message') ?? $response->body();
Log::channel('telepagos')->error("Telepagos {$actionDescription} failed: {$errorMessage}", array_merge([
'response_status' => $response->status(),
'response_body' => $this->sanitizeForLog($response->json() ?? $response->body()),
'client_id' => $this->clientContext?->id,
'integration_code' => $this->integrationCode,
], $context));
throw new Exception("Telepagos {$actionDescription} failed: {$errorMessage}");
}
return $response->json() ?? [];
}
/**
* Remove credentials and tokens before serializing provider responses.
*/
protected function sanitizeForLog(mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}
$sensitiveKeys = ['authorization', 'password', 'token', 'access_token', 'refresh_token'];
foreach ($value as $key => $item) {
if (in_array(strtolower((string) $key), $sensitiveKeys, true)) {
$value[$key] = '[REDACTED]';
continue;
}
$value[$key] = $this->sanitizeForLog($item);
}
return $value;
}
/**
* Clear the cached token.
*/
public function clearToken(): void
{
if (! $this->integrationInstance) {
return;
}
$cacheKey = $this->integrationInstance->tokenCacheKey();
Cache::forget($cacheKey);
}
/**
* Perform initial setup validation for Telepagos.
*
* @throws Exception
*/
public function onSetup(): void
{
// Realiza un login de prueba para validar que las credenciales son correctas.
$this->login();
}
}

View File

@@ -0,0 +1,333 @@
<?php
namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\TelepagosPayment;
use App\Domains\Purchase\Models\TelepagosQr;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Purchase\Services\DniDistanceService;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TelepagosWebhookService
{
public function __construct(
private readonly CheckoutService $checkoutService,
private readonly DniDistanceService $dniDistance,
) {}
/**
* Handle the Telepagos webhook notification.
*
* @throws Exception
*/
public function handleWebhook(Client $client, string $cashinId): void
{
Log::channel('telepagos')->info('Telepagos webhook received.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
]);
$telepagosService = new TelepagosIntegrationService;
$telepagosService->forClient($client);
try {
$details = $telepagosService->getCashinDetails($cashinId);
$qrOrderId = $details['data']['qr_order_id'] ?? $details['qr_order_id'] ?? null;
$amount = $this->normalizeAmount($details['data']['amount'] ?? $details['amount'] ?? 0);
$operationId = $details['data']['operation_id'] ?? $details['operation_id'] ?? null;
$paymentData = [
'compra_id' => null,
'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null,
'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null,
'amount' => $amount,
'concept' => $details['data']['concept'] ?? $details['concept'] ?? null,
'operation' => $details['data']['operation'] ?? $details['operation'] ?? null,
'operation_id' => $details['data']['operation_id'] ?? $details['operation_id'] ?? null,
'transaction_id' => $details['data']['transaction_id'] ?? $details['transaction_id'] ?? null,
'qr_order_id' => $qrOrderId,
'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null,
];
$transferenciaOperationIds = [1, 3, 11];
$qrOperationIds = [31, 37, 47];
$compra = null;
if (in_array((int) $operationId, $transferenciaOperationIds, true)) {
$cuit = $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null;
if (! $cuit) {
Log::channel('telepagos')->warning('Telepagos webhook: CUIT not found for transfer.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
]);
return;
}
$dni = substr($cuit, 2, -1);
$tenantCodes = $client->tenants()->pluck('codigo');
$eligiblePurchases = Purchase::query()
->whereIn('tenant_codigo', $tenantCodes)
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
])
->where('payment_method', 'transfer');
$purchases = (clone $eligiblePurchases)
->where('total', $amount)
->latest()
->get()
->filter(fn (Purchase $purchase): bool => $purchase->transfer_payer_dni !== null
&& $this->dniDistance->distance($dni, $purchase->transfer_payer_dni) === 0)
->values();
$compra = $purchases->count() === 1 ? $purchases->first() : null;
if (! $compra) {
$candidatePurchases = $this->findTransferCandidates(
$eligiblePurchases,
$dni,
$amount,
);
if ($candidatePurchases->isNotEmpty()) {
$payment = $this->storeTransferCandidates(
$paymentData,
$candidatePurchases,
$dni,
$amount,
);
Log::channel('telepagos')->info('Telepagos webhook: Transfer payment candidates found.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'telepagos_payment_id' => $payment->id,
'amount' => $amount,
'candidate_count' => $payment->candidates->count(),
'candidates' => $payment->candidates
->map(fn ($candidate): array => [
'purchase_id' => $candidate->compra_id,
'match_reason' => $candidate->match_reason,
'dni_distance' => $candidate->dni_distance,
'amount_difference' => $candidate->amount_difference,
'confidence' => $candidate->confidence,
])
->all(),
]);
}
Log::channel('telepagos')->warning('Telepagos webhook: Expected exactly one matching purchase.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'amount' => $amount,
'matches' => $purchases->count(),
]);
return;
}
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
if (! $qrOrderId) {
Log::channel('telepagos')->warning('Telepagos webhook: qr_order_id not present in provider response.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
]);
return;
}
$telepagosQr = TelepagosQr::where('qr_order_id', $qrOrderId)->first();
if (! $telepagosQr) {
Log::channel('telepagos')->warning('Telepagos webhook: QR not found in database.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'qr_order_id' => $qrOrderId,
]);
return;
}
$compra = $telepagosQr->compra;
if (! $compra) {
Log::channel('telepagos')->warning('Telepagos webhook: Purchase not found for QR.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'qr_order_id' => $qrOrderId,
]);
return;
}
if (! $client->tenants()->where('codigo', $compra->tenant_codigo)->exists()) {
Log::channel('telepagos')->warning('Telepagos webhook: Purchase does not belong to client.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'purchase_id' => $compra->id,
]);
return;
}
if (! in_array($compra->status, [
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
Log::channel('telepagos')->warning('Telepagos webhook: Purchase is not awaiting payment confirmation.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'purchase_id' => $compra->id,
'purchase_status' => $compra->status,
]);
return;
}
$totalAmount = $this->normalizeAmount($compra->getTotalAmount());
if ($amount !== $totalAmount) {
Log::channel('telepagos')->warning('Telepagos webhook: Amount mismatch.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'purchase_id' => $compra->id,
'cashin_amount' => $amount,
'purchase_amount' => $totalAmount,
]);
return;
}
} else {
Log::channel('telepagos')->warning('Telepagos webhook: Unknown operation_id.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'operation_id' => $operationId,
]);
return;
}
$paymentData['compra_id'] = $compra->id;
DB::transaction(function () use ($compra, $paymentData) {
TelepagosPayment::create($paymentData);
$this->checkoutService->confirmPaidPurchase($compra);
});
Log::channel('telepagos')->info('Telepagos webhook processed successfully.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'purchase_id' => $compra->id,
'transaction_id' => $paymentData['transaction_id'],
]);
} catch (Exception $e) {
Log::channel('telepagos')->error('Telepagos webhook processing failed.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
protected function normalizeAmount(mixed $amount): string
{
return number_format((float) $amount, 2, '.', '');
}
/**
* @param Builder<Purchase> $eligiblePurchases
* @return Collection<int, Purchase>
*/
private function findTransferCandidates(Builder $eligiblePurchases, string $dni, string $amount): Collection
{
$tolerancePercentage = max(
0,
(float) config('purchase.transfer_candidate_amount_tolerance_percentage', 5),
);
$numericAmount = (float) $amount;
$tolerance = $numericAmount * ($tolerancePercentage / 100);
$minimumAmount = $this->normalizeAmount(max(0, $numericAmount - $tolerance));
$maximumAmount = $this->normalizeAmount($numericAmount + $tolerance);
return (clone $eligiblePurchases)
->whereBetween('total', [$minimumAmount, $maximumAmount])
->latest()
->get()
->filter(function (Purchase $purchase) use ($dni, $amount): bool {
if ($purchase->transfer_payer_dni === null) {
return false;
}
$purchaseAmount = $this->normalizeAmount($purchase->total);
$distance = $this->dniDistance->distance(
$dni,
(string) $purchase->transfer_payer_dni,
);
return $distance === 0
|| ($purchaseAmount === $amount && $distance <= 2);
})
->values();
}
/**
* @param array<string, mixed> $paymentData
* @param Collection<int, Purchase> $candidatePurchases
*/
private function storeTransferCandidates(
array $paymentData,
Collection $candidatePurchases,
string $dni,
string $amount,
): TelepagosPayment {
return DB::transaction(function () use ($paymentData, $candidatePurchases, $dni, $amount): TelepagosPayment {
$payment = TelepagosPayment::create($paymentData);
$payment->candidates()->createMany(
$candidatePurchases
->map(function (Purchase $purchase) use ($dni, $amount): array {
$purchaseAmount = $this->normalizeAmount($purchase->total);
$dniDistance = $this->dniDistance->distance(
$dni,
(string) $purchase->transfer_payer_dni,
);
$dniMatches = $dniDistance === 0;
$amountMatches = $purchaseAmount === $amount;
return [
'compra_id' => $purchase->id,
'dni_matches' => $dniMatches,
'dni_distance' => $dniDistance,
'payment_dni' => $dni,
'purchase_dni' => $purchase->transfer_payer_dni,
'amount_matches' => $amountMatches,
'payment_amount' => $amount,
'purchase_amount' => $purchaseAmount,
'amount_difference' => $this->normalizeAmount(
abs((float) $purchaseAmount - (float) $amount),
),
'match_reason' => $amountMatches
? ($dniMatches ? 'ambiguous_exact_match' : 'exact_amount_near_dni')
: 'exact_dni_near_amount',
'confidence' => $amountMatches
? ($dniMatches ? 'exact' : 'medium')
: 'high',
];
})
->all(),
);
return $payment->load('candidates');
});
}
}

View File

@@ -0,0 +1,48 @@
# Dominio Integration
## Modelo
- `Integration`: catálogo, URL base, `integration_data_schema` y `requires_configuration`.
- `IntegrationInstance`: configuración interna concreta con nombre. `integration_data` se cifra con `EncryptedIntegrationData`, se almacena en `longText` y nunca se devuelve en la API.
- `ClientIntegration` y `AdminWebsiteTypeIntegration`: asociaciones a instancias. La clave compuesta verifica el código de la instancia y la unicidad permite una instancia por integración y propietario.
Las instancias no se administran directamente por HTTP. Cada configuración enviada desde un cliente o tipo de sitio crea una instancia interna nueva y reemplaza únicamente la asociación de ese propietario. Al reemplazar o desvincular una instancia, esta se elimina si ya no tiene asociaciones con ningún cliente ni tipo de sitio; las instancias compartidas se conservan mientras tengan al menos una asociación.
## Resolución
`BaseIntegrationService::forTenant()` busca primero la asociación del cliente y después la del tipo de admin del tenant. Selecciona una configuración completa, sin mezclar credenciales entre niveles. Si una configuración está presente pero es inválida, produce un error en vez de recurrir a otra instancia.
`forClient()` usa únicamente la asociación del cliente: sin un tenant concreto no se elige un tipo de sitio. Si no existe una instancia y `requires_configuration` es verdadero, se genera un error. Para correo opcional, `MailService` usa el mailer global si no encuentra una instancia; cuando la encuentra, construye un transporte SMTP aislado identificado como `integration-smtp`.
Telepagos utiliza una clave de caché basada en el ID de instancia y una huella del texto cifrado. Volver a configurar el servicio con `forClient()` o `forTenant()` carga la configuración actual.
## Administración
Todas estas rutas llevan el prefijo `/api`, requieren `auth:sanctum` y el rol global `admin` mediante `IntegrationPolicy`. Los roles `adminapp`, `scanner` y `user` no administran configuraciones.
| Método | Ruta | Operación |
| --- | --- | --- |
| GET | `/clients/{client}/integrations[/{integration_code}]` | Consultar asociaciones directas. |
| PUT | `/clients/{client}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del cliente. Ejecuta el hook de configuración existente. |
| DELETE | `/clients/{client}/integrations/{integration_code}` | Desvincular. |
| GET | `/admin-website-types/{codigo}/integrations[/{integration_code}]` | Consultar asociaciones del tipo de admin. |
| PUT | `/admin-website-types/{codigo}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del tipo de admin. |
| DELETE | `/admin-website-types/{codigo}/integrations/{integration_code}` | Desvincular. |
Los dos `PUT` reciben `integration_data`, un objeto completo validado según el esquema de la integración. La integración debe existir previamente en el catálogo interno. No hay endpoints públicos para administrar el catálogo ni las instancias directamente.
Las respuestas y consultas incluyen metadatos de `integration_instance`, pero nunca sus credenciales. La configuración del cliente conserva su mensaje de respuesta histórico; la del tipo de sitio usa un resource con envoltorio `data`.
## Webhooks y contexto operativo
`POST /webhooks/telepagos/{client}` conserva su contrato público con el proveedor y la validación de pertenencia de las compras al cliente. Como no recibe un tenant, requiere una asociación directa al cliente. Para usar una instancia compartida en ese flujo, asociarla también al cliente; la herencia por tipo de sitio no se aplica a esa URL.
`Notification` consume `MailService`; `Purchase` consume Telepagos. Los tenants mantienen el contexto operativo y el branding.
## Despliegue
Ejecutar `php artisan migrate` junto con este código. La migración `2026_09_04_000003` renombra `requires_client_configuration` a `requires_configuration` conservando sus valores. Los payloads del catálogo deben usar el nuevo nombre. Las migraciones previas trasladan el texto cifrado sin descifrarlo y no comparten instancias automáticamente.
## Logging
Telepagos registra eventos en el canal diario `telepagos`. El nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Se eliminan tokens y credenciales de las estructuras registradas.

View File

@@ -0,0 +1,25 @@
<?php
use App\Domains\Integration\Controllers\ClientIntegrationController;
use App\Domains\Integration\Controllers\TelepagosWebhookController;
use App\Domains\Integration\Controllers\AdminWebsiteTypeIntegrationController;
use App\Domains\Integration\Models\Integration;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth:sanctum', 'can:manage,'.Integration::class])->group(function (): void {
Route::prefix('admin-website-types/{adminWebsiteType:codigo}/integrations')->group(function (): void {
Route::get('/', [AdminWebsiteTypeIntegrationController::class, 'index']);
Route::get('/{integration_code}', [AdminWebsiteTypeIntegrationController::class, 'show']);
Route::put('/{integration_code}', [AdminWebsiteTypeIntegrationController::class, 'store']);
Route::delete('/{integration_code}', [AdminWebsiteTypeIntegrationController::class, 'destroy']);
});
Route::group(['prefix' => 'clients/{client}/integrations'], function () {
Route::get('/', [ClientIntegrationController::class, 'index']);
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
Route::delete('/{integration_code}', [ClientIntegrationController::class, 'destroy']);
});
});
Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']);

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Logging\Enums;
enum ValueChangeActorType: string
{
case User = 'user';
case System = 'system';
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Domains\Logging\Models\Concerns;
use App\Domains\Logging\Enums\ValueChangeActorType;
use App\Domains\Logging\Models\ValueChange;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Facades\Auth;
use LogicException;
trait LogsValueChanges
{
abstract protected function valueChangeTenantCode(): string;
public static function bootLogsValueChanges(): void
{
static::updated(function (Model $model): void {
$changedAttributes = array_values(array_intersect(
$model->getLoggedAttributes(),
array_keys($model->getChanges()),
));
if ($changedAttributes === []) {
return;
}
$userId = Auth::id();
$actorType = $userId === null
? ValueChangeActorType::System
: ValueChangeActorType::User;
foreach ($changedAttributes as $attribute) {
$model->valueChanges()->create([
'tenant_code' => $model->valueChangeTenantCode(),
'attribute' => $attribute,
'old_value' => $model->getRawOriginal($attribute),
'new_value' => $model->getAttributes()[$attribute] ?? null,
'changed_at' => now(),
'actor_type' => $actorType,
'user_id' => $userId,
]);
}
});
}
/** @return array<int, string> */
public function getLoggedAttributes(): array
{
if (! property_exists($this, 'loggedAttributes')) {
throw new LogicException(sprintf(
'The [%s] model must define a $loggedAttributes property.',
static::class,
));
}
return array_values(array_unique($this->loggedAttributes));
}
/** @return MorphMany<ValueChange, $this> */
public function valueChanges(): MorphMany
{
return $this->morphMany(ValueChange::class, 'trackable');
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Domains\Logging\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Logging\Enums\ValueChangeActorType;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Fillable([
'tenant_code',
'trackable_type',
'trackable_id',
'attribute',
'old_value',
'new_value',
'changed_at',
'actor_type',
'user_id',
])]
class ValueChange extends Model
{
public $timestamps = false;
/** @return MorphTo<Model, $this> */
public function trackable(): MorphTo
{
return $this->morphTo();
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
protected function casts(): array
{
return [
'trackable_id' => 'integer',
'changed_at' => 'datetime',
'actor_type' => ValueChangeActorType::class,
'user_id' => 'integer',
];
}
}

View File

@@ -0,0 +1,25 @@
# Dominio Logging
## Propósito
Registra cambios relevantes de valores en modelos de negocio, indicando tenant, atributo, valor anterior/nuevo, fecha y actor.
## Componentes
- `Models/ValueChange.php`: entrada persistida del historial, relacionada polimórficamente con el objeto modificado.
- `Models/Concerns/LogsValueChanges.php`: trait reutilizable que escucha actualizaciones del modelo.
- `Enums/ValueChangeActorType.php`: distingue cambios realizados por usuario o por el sistema.
## Uso
Un modelo consumidor debe:
1. Usar el trait `LogsValueChanges`.
2. Declarar la propiedad `loggedAttributes` con los atributos auditables.
3. Implementar `valueChangeTenantCode()`.
El trait solo registra atributos configurados que efectivamente cambiaron. Si existe un usuario autenticado lo asocia al cambio; en caso contrario marca al sistema como actor.
## API y dependencias
No expone rutas HTTP. `Purchase` lo utiliza para auditar cambios de estado y `Sale` consulta esas modificaciones para reportes.

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\MailTest\Controllers;
use App\Domains\MailTest\Requests\SendTestMailRequest;
use App\Domains\MailTest\Services\MailTestService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class MailTestController extends Controller
{
public function __construct(
protected MailTestService $mailTestService,
) {}
public function __invoke(SendTestMailRequest $request, string $tenantCode): JsonResponse
{
$tenant = Tenant::query()
->where('codigo', $tenantCode)
->firstOrFail();
return response()->json(
$this->mailTestService->send(
$tenant,
$request->validated('to'),
$request->validated('subject'),
$request->validated('message'),
)
);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Domains\MailTest\Mailables;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class TestMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public readonly string $mailSubject,
public readonly string $mailMessage,
public readonly Tenant $tenant,
) {}
public function envelope(): Envelope
{
return new Envelope(subject: $this->mailSubject);
}
public function content(): Content
{
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
$branding = [
'name' => $this->tenant->nombre,
'primary_color' => $this->tenant->primary_color ?? '#6376f3',
'body_color' => '#334155',
'background_color' => '#f1f5f9',
'surface_color' => '#ffffff',
'header_bg_color' => $this->tenant->header_bg_color ?? '#ffffff',
'footer_bg_color' => $this->tenant->footer_bg_color ?? '#334155',
];
return new Content(
view: 'mail.test',
with: [
'tenant' => $this->tenant,
'branding' => $branding,
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
],
);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\MailTest\Requests;
use Illuminate\Foundation\Http\FormRequest;
class SendTestMailRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'to' => ['required', 'string', 'email', 'max:255'],
'subject' => ['nullable', 'string', 'max:255'],
'message' => ['nullable', 'string', 'max:5000'],
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Domains\MailTest\Services;
use App\Domains\Integration\Services\MailService;
use App\Domains\Tenant\Models\Tenant;
class MailTestService
{
/**
* @return array<string, string>
*/
public function send(Tenant $tenant, string $recipient, ?string $subject = null, ?string $message = null): array
{
$subject ??= 'Prueba de correo de Shopit';
$message ??= 'Este es un correo de prueba enviado desde Shopit.';
$mailService = (new MailService)->forTenant($tenant->codigo);
$mailService->send(
$recipient,
$subject,
'<h1 style="margin: 0 0 20px;">'.e($subject).'</h1>'
.'<p>'.nl2br(e($message)).'</p>',
);
return [
'code' => 'mail.test_sent',
'message' => __('api.mail.test_sent'),
'recipient' => $recipient,
'tenant_code' => $tenant->codigo,
'mailer' => $mailService->mailerName(),
'sent_at' => now()->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,24 @@
# Dominio MailTest
## Propósito
Ofrece una operación técnica para verificar la configuración de correo de un tenant sin ejecutar un flujo funcional real.
## Componentes
- `MailTestController`: endpoint invocable de envío.
- `SendTestMailRequest`: valida destinatario y contenido requerido.
- `MailTestService`: coordina el envío de prueba.
- `TestMail`: mailable utilizado para construir el mensaje.
## Endpoint
- `POST /{tenant_code}/mail-test/send`.
## Dependencias
Usa la configuración de correo del dominio `Integration` y resuelve el tenant indicado.
## Consideraciones
Es una herramienta de diagnóstico. Debe restringirse o deshabilitarse en entornos donde no corresponda exponer envíos de prueba, y nunca debe registrar credenciales.

View File

@@ -0,0 +1,6 @@
<?php
use App\Domains\MailTest\Controllers\MailTestController;
use Illuminate\Support\Facades\Route;
Route::post('{tenant_code}/mail-test/send', MailTestController::class);

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Domains\Notification\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PasswordResetRequested
{
use Dispatchable, SerializesModels;
public const CHANNEL_STOREFRONT = 'storefront';
public const CHANNEL_ADMINAPP = 'adminapp';
public const CHANNEL_SCANNER = 'scanner';
public function __construct(
public readonly int $attemptId,
public readonly string $tenantCode,
public readonly string $channel = self::CHANNEL_STOREFRONT,
) {}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Notification\Events;
use App\Domains\Auth\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UserRegistered
{
use Dispatchable, SerializesModels;
public function __construct(
public readonly User $user,
public readonly string $tenantCode,
) {}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Notification\Listeners;
use App\Domains\Event\Events\EventDateRescheduled;
use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
class SendEventDateRescheduledEmails implements ShouldQueueAfterCommit
{
use InteractsWithQueue;
public string $queue = 'emails';
public int $tries = 3;
/** @var array<int, int> */
public array $backoff = [30, 120, 300];
public function handle(EventDateRescheduled $event): void
{
app(NotificationMailService::class)->sendEventDateRescheduled(
$event->tenantCode,
$event->sourceEventDateId,
$event->destinationEventDateId,
$event->previousDate,
$event->newDate,
$event->purchaseTickets,
);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Notification\Listeners;
use App\Domains\Event\Events\EventDateSuspended;
use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
class SendEventDateSuspendedEmails implements ShouldQueueAfterCommit
{
use InteractsWithQueue;
public string $queue = 'emails';
public int $tries = 3;
/** @var array<int, int> */
public array $backoff = [30, 120, 300];
public function handle(EventDateSuspended $event): void
{
app(NotificationMailService::class)->sendEventDateSuspended(
$event->tenantCode,
$event->eventDateId,
$event->date,
$event->purchaseTickets,
);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\Notification\Listeners;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
class SendPasswordResetEmail implements ShouldQueueAfterCommit
{
use InteractsWithQueue;
public string $queue = 'emails';
public int $tries = 3;
/** @var array<int, int> */
public array $backoff = [30, 120, 300];
public function handle(PasswordResetRequested $event): void
{
app(NotificationMailService::class)->sendPasswordResetCode(
$event->attemptId,
$event->tenantCode,
$event->channel,
);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Notification\Listeners;
use App\Domains\Notification\Services\NotificationMailService;
use App\Domains\Purchase\Events\PurchasePaid;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
class SendPurchaseConfirmedEmail implements ShouldQueueAfterCommit
{
use InteractsWithQueue;
public string $queue = 'emails';
public int $tries = 3;
/** @var array<int, int> */
public array $backoff = [30, 120, 300];
public function handle(PurchasePaid $event): void
{
app(NotificationMailService::class)->sendPurchaseConfirmed($event->purchaseId);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Notification\Listeners;
use App\Domains\Notification\Events\UserRegistered;
use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
class SendWelcomeEmail implements ShouldQueueAfterCommit
{
use InteractsWithQueue;
public string $queue = 'emails';
public int $tries = 3;
/** @var array<int, int> */
public array $backoff = [30, 120, 300];
public function handle(UserRegistered $event): void
{
app(NotificationMailService::class)->sendWelcome($event->user->getKey(), $event->tenantCode);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Domains\Notification\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'idempotency_key',
'email_type',
'tenant_code',
'status',
'attempts',
'context',
'recipient_fingerprint',
'claim_token',
'claimed_at',
'lease_expires_at',
'sent_at',
'failed_at',
'last_error',
])]
class EmailDelivery extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_PROCESSING = 'processing';
public const STATUS_SENT = 'sent';
public const STATUS_FAILED = 'failed';
protected function casts(): array
{
return [
'attempts' => 'integer',
'context' => 'array',
'claimed_at' => 'datetime',
'lease_expires_at' => 'datetime',
'sent_at' => 'datetime',
'failed_at' => 'datetime',
];
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Domains\Notification\Services;
use App\Domains\Notification\Models\EmailDelivery;
use Closure;
use Illuminate\Database\Query\Expression;
use Illuminate\Support\Str;
use Throwable;
class IdempotentEmailDeliveryService
{
/**
* @param array<string, mixed> $context
* @param Closure(): void $send
*/
public function sendOnce(
string $key,
string $type,
?string $tenantCode,
array $context,
string $recipient,
Closure $send,
): bool {
$now = now();
EmailDelivery::query()->insertOrIgnore([
'idempotency_key' => $key,
'email_type' => $type,
'tenant_code' => $tenantCode,
'status' => EmailDelivery::STATUS_PENDING,
'attempts' => 0,
'context' => json_encode($context, JSON_THROW_ON_ERROR),
'recipient_fingerprint' => $this->recipientFingerprint($recipient),
'created_at' => $now,
'updated_at' => $now,
]);
$claimToken = (string) Str::uuid();
$leaseExpiresAt = $now->copy()->addSeconds(
max(1, (int) config('mail.delivery_lease_seconds', 300)),
);
$claimed = EmailDelivery::query()
->where('idempotency_key', $key)
->where(function ($query) use ($now): void {
$query->whereIn('status', [
EmailDelivery::STATUS_PENDING,
EmailDelivery::STATUS_FAILED,
])->orWhere(function ($query) use ($now): void {
$query->where('status', EmailDelivery::STATUS_PROCESSING)
->where('lease_expires_at', '<=', $now);
});
})
->update([
'status' => EmailDelivery::STATUS_PROCESSING,
'attempts' => new Expression('attempts + 1'),
'context' => json_encode($context, JSON_THROW_ON_ERROR),
'recipient_fingerprint' => $this->recipientFingerprint($recipient),
'claim_token' => $claimToken,
'claimed_at' => $now,
'lease_expires_at' => $leaseExpiresAt,
'failed_at' => null,
'last_error' => null,
'updated_at' => $now,
]) === 1;
if (! $claimed) {
return false;
}
try {
$send();
EmailDelivery::query()
->where('idempotency_key', $key)
->where('claim_token', $claimToken)
->update([
'status' => EmailDelivery::STATUS_SENT,
'claim_token' => null,
'lease_expires_at' => null,
'sent_at' => now(),
'updated_at' => now(),
]);
} catch (Throwable $exception) {
EmailDelivery::query()
->where('idempotency_key', $key)
->where('claim_token', $claimToken)
->update([
'status' => EmailDelivery::STATUS_FAILED,
'claim_token' => null,
'lease_expires_at' => null,
'failed_at' => now(),
'last_error' => Str::limit($exception::class, 2000, ''),
'updated_at' => now(),
]);
throw $exception;
}
return true;
}
private function recipientFingerprint(string $recipient): string
{
return hash_hmac(
'sha256',
mb_strtolower(trim($recipient)),
(string) config('app.key'),
);
}
}

View File

@@ -0,0 +1,495 @@
<?php
namespace App\Domains\Notification\Services;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Integration\Services\MailService;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Services\TicketPdfService;
use App\Domains\Ticket\Services\TicketPresentationResolver;
use App\Domains\Ticket\Services\TicketValidityResolver;
use Closure;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Throwable;
class NotificationMailService
{
public function __construct(
private readonly MailService $mailService,
private readonly TicketPdfService $ticketPdfService,
private readonly IdempotentEmailDeliveryService $emailDeliveryService,
) {}
public function sendWelcome(int $userId, string $tenantCode): void
{
$context = [
'user_id' => $userId,
'tenant_code' => $tenantCode,
];
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
$user = User::query()->findOrFail($userId);
$this->sendIdempotently(
"welcome:{$tenantCode}:{$userId}",
'welcome',
$tenantCode,
$context,
$user->email,
function () use ($user, $tenant, $tenantCode): array {
$brand = $tenant;
$tenantUrl = 'https://'.$tenant->dominio.$tenant->base_path;
$this->mailService
->forTenant($tenantCode)
->send(
$user->email,
"Bienvenido a {$brand->nombre}",
view('mail.notifications.welcome', compact('brand', 'user', 'tenantUrl'))->render(),
$brand,
);
return [
'brand_type' => 'tenant',
];
},
);
}
public function sendPasswordResetCode(
int $attemptId,
string $tenantCode,
string $channel = PasswordResetRequested::CHANNEL_STOREFRONT,
): void {
$context = [
'attempt_id' => $attemptId,
'tenant_code' => $tenantCode,
'channel' => $channel,
];
$tenant = Tenant::query()
->with('adminWebsiteType')
->where('codigo', $tenantCode)
->firstOrFail();
$attempt = ResetPasswordAttempt::query()
->with('user')
->findOrFail($attemptId);
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
$this->logSkipped('password_reset', array_merge($context, [
'reason' => 'attempt_not_pending',
'attempt_status' => $attempt->status,
'user_id' => $attempt->user_id,
]));
return;
}
$this->sendIdempotently(
"password-reset:{$attemptId}",
'password_reset',
$tenantCode,
$context,
$attempt->user->email,
function () use ($attempt, $tenant, $tenantCode, $channel): array {
$recoveryDomain = match ($channel) {
PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->adminWebsiteType?->dominio,
PasswordResetRequested::CHANNEL_SCANNER => $tenant->adminWebsiteType?->scanner_domain,
default => $tenant->dominio,
};
$recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
&& $tenant->base_path !== '/'
? $tenant->base_path
: '';
$recoveryQuery = ['email' => $attempt->user->email];
if (
$channel === PasswordResetRequested::CHANNEL_SCANNER
&& $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED
) {
$recoveryQuery['code'] = $attempt->codigo;
}
$recoveryUrl = $recoveryDomain === null
? null
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
$brand = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
? $tenant
: ($tenant->adminWebsiteType ?? $tenant);
[$subject, $template] = match ($attempt->reason) {
ResetPasswordAttempt::REASON_STAFF_CREATED => [
'Tu cuenta de escáner está lista', 'scanner-created',
],
ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED => [
'Tu cuenta de administrador está lista', 'administrator-created',
],
ResetPasswordAttempt::REASON_ACCOUNT_LOCKED => [
'Desbloqueá tu cuenta', 'account-locked',
],
default => ['Código para recuperar tu contraseña', 'password-reset'],
};
$this->mailService
->forTenant($tenantCode)
->send(
$attempt->user->email,
"{$subject} - {$brand->nombre}",
view("mail.notifications.{$template}", [
'attempt' => $attempt,
'recoveryUrl' => $recoveryUrl,
'brand' => $brand,
])->render(),
$brand,
);
return [
'user_id' => $attempt->user_id,
'recovery_domain_available' => $recoveryDomain !== null,
];
},
);
}
public function sendPurchaseConfirmed(int $purchaseId): void
{
$context = ['purchase_id' => $purchaseId];
$purchase = Purchase::query()
->with(['tenant', 'user', 'items'])
->find($purchaseId);
if ($purchase === null) {
$this->logSkipped('purchase_confirmed', array_merge($context, [
'reason' => 'purchase_not_found',
'missing_model' => Purchase::class,
]));
return;
}
$recipient = $this->recipientFor($purchase);
if ($recipient === '') {
$this->logSkipped('purchase_confirmed', array_merge($context, ['reason' => 'missing_recipient']));
return;
}
$this->sendIdempotently(
"purchase-confirmed:{$purchaseId}",
'purchase_confirmed',
$purchase->tenant_codigo,
$context,
$recipient,
function () use ($purchase, $recipient): array {
/** @var Collection<int, Ticket> $tickets */
$tickets = $purchase->tickets()
->where('tenant_code', $purchase->tenant_codigo)
->with(TicketPresentationResolver::RELATIONS)
->get();
$attachments = $tickets->isEmpty()
? []
: [[
'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets),
'name' => $this->ticketPdfService->filename($tickets),
'mime' => 'application/pdf',
]];
$this->mailService
->forTenant($purchase->tenant_codigo)
->send(
$recipient,
"Compra confirmada - Compra #{$purchase->getKey()}",
view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(),
attachments: $attachments,
);
return [
'tenant_code' => $purchase->tenant_codigo,
'user_id' => $purchase->user_id,
'purchase_status' => $purchase->status,
'purchase_item_count' => $purchase->items->count(),
'ticket_count' => $tickets->count(),
'ticket_ids' => $tickets->modelKeys(),
];
},
);
}
/**
* @param list<array{purchase_id: int, ticket_ids: list<int>}> $purchaseTickets
*/
public function sendEventDateRescheduled(
string $tenantCode,
int $sourceEventDateId,
int $destinationEventDateId,
string $previousDate,
string $newDate,
array $purchaseTickets,
): void {
foreach ($purchaseTickets as $purchaseTicketGroup) {
$purchaseId = $purchaseTicketGroup['purchase_id'];
$ticketIds = $purchaseTicketGroup['ticket_ids'];
$context = [
'tenant_code' => $tenantCode,
'event_date_id' => $sourceEventDateId,
'destination_event_date_id' => $destinationEventDateId,
'purchase_id' => $purchaseId,
'ticket_ids' => $ticketIds,
];
$purchase = $this->eventDateNotificationPurchase($tenantCode, $purchaseId);
if ($purchase === null || $purchase->status !== Purchase::STATUS_PAID) {
$this->logSkipped('event_date_rescheduled', array_merge($context, [
'reason' => 'purchase_not_paid_or_not_found',
]));
continue;
}
/** @var Collection<int, Ticket> $tickets */
$tickets = $purchase->tickets()
->where('tenant_code', $tenantCode)
->whereKey($ticketIds)
->with([
...TicketPresentationResolver::RELATIONS,
...TicketValidityResolver::RELATIONS,
])
->get()
->filter(fn (Ticket $ticket): bool => $ticket->is_active())
->values();
if ($tickets->isEmpty()) {
$this->logSkipped('event_date_rescheduled', array_merge($context, [
'reason' => 'no_longer_active_tickets',
]));
continue;
}
$recipient = $this->recipientFor($purchase);
if ($recipient === '') {
$this->logSkipped('event_date_rescheduled', array_merge($context, [
'reason' => 'missing_recipient',
]));
continue;
}
$deliveryKey = "event-date-rescheduled:{$sourceEventDateId}:{$destinationEventDateId}:{$purchaseId}";
$this->sendIdempotently(
$deliveryKey,
'event_date_rescheduled',
$tenantCode,
$context,
$recipient,
function () use (
$tenantCode,
$purchase,
$recipient,
$previousDate,
$newDate,
$tickets,
): array {
$brand = $purchase->tenant;
$this->mailService
->forTenant($tenantCode)
->send(
$recipient,
"Tu evento fue reprogramado - N° de Orden #{$purchase->getKey()}",
view('mail.notifications.event-date-rescheduled', compact(
'purchase', 'previousDate', 'newDate', 'tickets'
))->render(),
$brand,
);
return ['ticket_count' => $tickets->count()];
},
);
}
}
/**
* @param list<array{purchase_id: int, ticket_ids: list<int>}> $purchaseTickets
*/
public function sendEventDateSuspended(
string $tenantCode,
int $eventDateId,
string $date,
array $purchaseTickets,
): void {
foreach ($purchaseTickets as $purchaseTicketGroup) {
$purchaseId = $purchaseTicketGroup['purchase_id'];
$ticketIds = $purchaseTicketGroup['ticket_ids'];
$context = [
'tenant_code' => $tenantCode,
'event_date_id' => $eventDateId,
'purchase_id' => $purchaseId,
'ticket_ids' => $ticketIds,
];
$purchase = $this->eventDateNotificationPurchase($tenantCode, $purchaseId);
if ($purchase === null || $purchase->status !== Purchase::STATUS_PAID) {
$this->logSkipped('event_date_suspended', array_merge($context, [
'reason' => 'purchase_not_paid_or_not_found',
]));
continue;
}
/** @var Collection<int, Ticket> $tickets */
$tickets = $purchase->tickets()
->where('tenant_code', $tenantCode)
->whereKey($ticketIds)
->with([
...TicketPresentationResolver::RELATIONS,
...TicketValidityResolver::RELATIONS,
])
->get()
->filter(fn (Ticket $ticket): bool => in_array($ticket->status, [
Ticket::STATUS_ACTIVE,
Ticket::STATUS_DISABLED,
], true))
->values();
if ($tickets->isEmpty()) {
$this->logSkipped('event_date_suspended', array_merge($context, [
'reason' => 'no_longer_relevant_tickets',
]));
continue;
}
$recipient = $this->recipientFor($purchase);
if ($recipient === '') {
$this->logSkipped('event_date_suspended', array_merge($context, [
'reason' => 'missing_recipient',
]));
continue;
}
$deliveryKey = "event-date-suspended:{$eventDateId}:{$purchaseId}";
$disabledTickets = $tickets
->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_DISABLED)
->values();
$activeTickets = $tickets
->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_ACTIVE)
->values();
$this->sendIdempotently(
$deliveryKey,
'event_date_suspended',
$tenantCode,
$context,
$recipient,
function () use (
$tenantCode,
$purchase,
$recipient,
$date,
$disabledTickets,
$activeTickets,
): array {
$brand = $purchase->tenant;
$this->mailService
->forTenant($tenantCode)
->send(
$recipient,
"Una fecha de tu evento fue suspendida - N° de Orden #{$purchase->getKey()}",
view('mail.notifications.event-date-suspended', compact(
'purchase', 'date', 'disabledTickets', 'activeTickets'
))->render(),
$brand,
);
return [
'ticket_count' => $disabledTickets->count() + $activeTickets->count(),
'disabled_ticket_ids' => $disabledTickets->modelKeys(),
'active_ticket_ids' => $activeTickets->modelKeys(),
];
},
);
}
}
private function eventDateNotificationPurchase(string $tenantCode, int $purchaseId): ?Purchase
{
return Purchase::query()
->where('tenant_codigo', $tenantCode)
->with(['tenant', 'user'])
->find($purchaseId);
}
private function recipientFor(Purchase $purchase): string
{
return (string) ($purchase->email ?: $purchase->user?->email);
}
/**
* @param array<string, mixed> $context
* @param Closure(): array<string, mixed> $send
*/
private function sendIdempotently(
string $key,
string $emailType,
?string $tenantCode,
array $context,
string $recipient,
Closure $send,
): void {
$sent = $this->emailDeliveryService->sendOnce(
$key,
$emailType,
$tenantCode,
$context,
$recipient,
function () use ($emailType, $context, $send): void {
$this->sendLogged($emailType, $context, $send);
},
);
if (! $sent) {
$this->logSkipped($emailType, array_merge($context, ['reason' => 'already_claimed']));
}
}
/**
* @param array<string, mixed> $context
* @param Closure(): (array<string, mixed>|null) $send
*/
private function sendLogged(string $emailType, array $context, Closure $send): void
{
try {
$resultContext = $send();
if ($resultContext === null) {
return;
}
Log::channel('emails')->info('Notification email sent.', array_merge($context, $resultContext, [
'email_type' => $emailType,
'mailer' => $this->mailService->mailerName(),
]));
} catch (Throwable $exception) {
Log::channel('emails')->error('Notification email delivery failed.', array_merge($context, [
'email_type' => $emailType,
'exception' => $exception,
]));
throw $exception;
}
}
/** @param array<string, mixed> $context */
private function logSkipped(string $emailType, array $context): void
{
Log::channel('emails')->warning('Notification email skipped.', array_merge($context, [
'email_type' => $emailType,
]));
}
}

View File

@@ -0,0 +1,48 @@
# Dominio Notification
## Propósito
Orquesta notificaciones de negocio por correo a partir de eventos de otros dominios.
## Eventos atendidos
- `UserRegistered`: dispara el correo de bienvenida.
- `PasswordResetRequested`: envía el código de recuperación si el intento sigue pendiente.
- `PurchasePaid`: envía la confirmación de compra y adjunta los tickets generados, cuando corresponde.
## Componentes
Los listeners delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`.
`IdempotentEmailDeliveryService` coordina los envíos automáticos mediante la tabla
`email_deliveries`. Cada correo utiliza una clave de negocio única:
- bienvenida: `welcome:{tenant_code}:{user_id}`;
- recuperación: `password-reset:{attempt_id}`;
- compra confirmada: `purchase-confirmed:{purchase_id}`;
- reprogramación: `event-date-rescheduled:{source_event_date_id}:{destination_event_date_id}:{purchase_id}`;
- suspensión: `event-date-suspended:{event_date_id}:{purchase_id}`.
Los correos de prueba y de validación de una integración SMTP no usan esta capa,
porque su reenvío explícito es parte de su comportamiento esperado.
## API y dependencias
No expone rutas HTTP. Consume datos de `Auth`, `Tenant`, `Purchase` y `Ticket`, y delega la entrega al dominio `Integration`.
## Consideraciones
- Los listeners reciben identificadores y vuelven a cargar los modelos, evitando transportar entidades obsoletas.
- La recuperación no se envía si el intento dejó de estar pendiente.
- La bienvenida y la recuperación del storefront usan la identidad visual del tenant. La recuperación del admin y scanner usa el `AdminWebsiteType`, con fallback al tenant si no tiene uno configurado.
- El correo transaccional de compra confirmada usa la identidad visual del tenant y adjunta un único PDF cuando la compra generó tickets.
- Los handlers deben permanecer idempotentes o tolerantes a reintentos de cola.
- Una entrega queda en estado `processing` mientras un worker posee su claim. Si
el worker se interrumpe, el claim vence según `EMAIL_DELIVERY_LEASE_SECONDS` y
otro intento puede recuperarlo.
- Los fallos quedan registrados como `failed` y pueden ser retomados por los
reintentos de la cola. Los envíos exitosos permanecen como `sent` y las llamadas
posteriores con la misma clave no vuelven a enviar el correo.
- SMTP no ofrece una confirmación transaccional junto con la base de datos. Una
interrupción ocurrida después de entregar el correo y antes de registrar
`sent` puede producir un duplicado excepcional al recuperar el claim.

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Domains\Shared\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class CroppedImageOrBase64Rule implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_array($value)) {
(new ImageOrBase64Rule)->validate($attribute, $value, $fail);
return;
}
if (! array_key_exists('image', $value)) {
$fail('The :attribute.image field is required.');
return;
}
$valid = true;
(new ImageOrBase64Rule)->validate(
"{$attribute}.image",
$value['image'],
function (string $message) use ($fail, &$valid): void {
$valid = false;
$fail($message);
}
);
if (! $valid) {
return;
}
if (isset($value['crops']) && is_array($value['crops'])) {
foreach (['desktop', 'mobile'] as $variant) {
$crop = $value['crops'][$variant] ?? null;
if (! is_array($crop)) {
$fail("The :attribute.crops.{$variant} field must be an object.");
continue;
}
$this->validateRange("{$attribute}.crops.{$variant}", 'crop_horizontal', $crop, $fail);
$this->validateRange("{$attribute}.crops.{$variant}", 'crop_vertical', $crop, $fail);
}
return;
}
// Backwards compatibility with the original single-crop payload.
$this->validateRange($attribute, 'crop_horizontal', $value, $fail);
$this->validateRange($attribute, 'crop_vertical', $value, $fail);
}
private function validateRange(string $attribute, string $axis, array $value, Closure $fail): void
{
$range = $value[$axis] ?? null;
if (! is_array($range)) {
$fail("The :attribute.{$axis} field must be an object.");
return;
}
$start = $range['start_percentage'] ?? null;
$end = $range['end_percentage'] ?? null;
if (
! is_numeric($start)
|| ! is_numeric($end)
|| (float) $start < 0
|| (float) $end > 100
|| (float) $start >= (float) $end
) {
$fail(
"The :attribute.{$axis} field must satisfy "
.'0 <= start_percentage < end_percentage <= 100.'
);
}
}
}

View File

@@ -0,0 +1,58 @@
<?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 (\Illuminate\Support\Str::isUuid($payload)) {
return;
}
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.");
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Shared\Rules;
use Closure;
use DateTimeZone;
use Exception;
use Illuminate\Contracts\Validation\ValidationRule;
class ValidTimezone implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
return;
}
try {
new DateTimeZone($value);
} catch (Exception) {
$fail(__('validation.timezone'));
}
}
}

View 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),
)
);
}
}

View File

@@ -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'],
];
}
}

View 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'],
];
}
}

View 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();
}
}

View File

@@ -0,0 +1,23 @@
# Dominio StorageTest
## Propósito
Expone operaciones técnicas para comprobar la escritura en S3 y la generación de URL temporales.
## Componentes
- `S3TestController`: recibe solicitudes de carga y URL temporal.
- `S3TestService`: almacena un archivo de prueba y genera el enlace firmado.
- `StoreS3TestFileRequest`: valida la carga.
- `GenerateS3TemporaryUrlRequest`: valida ruta y tiempo de expiración.
## Endpoints
Bajo `/storage-test/s3`:
- `POST /upload`.
- `GET /temporary-url`.
## Consideraciones
Es infraestructura de diagnóstico, no una API funcional de archivos. Debe restringirse por entorno o autorización. Para adjuntos de negocio se debe usar el dominio `Attachable`.

View 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']);
});

View File

@@ -0,0 +1,18 @@
# Dominio Shared
## Propósito
Contiene contratos técnicos reutilizables que no pertenecen a un único dominio funcional.
## Componentes
- `Enums/FieldType.php`: tipos de campos dinámicos y helpers para determinar si admiten opciones estáticas o dinámicas.
- `Rules/ImageOrBase64Rule.php`: regla de validación para aceptar una imagen subida o codificada en Base64.
## API
No posee modelos persistentes, controladores ni rutas. Sus elementos se importan desde requests y servicios de otros dominios.
## Criterio de pertenencia
Solo deben incorporarse aquí conceptos verdaderamente transversales. Una regla o enum con significado de negocio específico debe permanecer en su dominio propietario.