Compare commits

...

93 Commits

Author SHA1 Message Date
5503559899 feat(refund): implement backfill command for historical ticket refunds reconciliation 2026-09-17 12:29:33 -03:00
830f65c402 feat(refund): add support for event date suspension in cart invalidation process 2026-09-17 11:42:33 -03:00
df6892c662 feat(checkout): enhance cancellation logic to handle unavailable variants and update cart status 2026-09-17 11:34:26 -03:00
de08ee4ac7 feat(cart): implement InvalidateEventDateCartsService to handle cart invalidation for rescheduled event dates 2026-09-17 10:27:27 -03:00
1269ebcda0 feat(ticket): exclude cancelled status from common ticket filter fields 2026-09-17 09:38:57 -03:00
04e54fefad feat(notification): update ticket and order number formatting in event suspension emails 2026-09-16 16:35:51 -03:00
a46bf905e8 feat(refund): enhance refund process by adding backfill for refunded_units and updating inventory restoration logic 2026-09-16 15:00:41 -03:00
353fd34db2 feat(inventory): add refunded_units to inventory model and update related services 2026-09-16 15:00:28 -03:00
f8a3296821 feat(event): add effective date resolution for rescheduled events and update tests 2026-09-16 14:27:35 -03:00
9aa0c1f3b9 feat(entry): enhance variant filtering in all method to exclude replaced and disabled variants 2026-09-16 10:14:54 -03:00
708037677a feat(entry): add validation for single non-replaced variant in EntryResource and update tests 2026-09-16 10:08:39 -03:00
e6b5da366c feat(entry): filter out replaced variants in EntryResource to improve data accuracy 2026-09-16 09:55:28 -03:00
69bea10519 feat(entry): simplify variant data handling in EntryResource by removing replaced variant logic 2026-09-16 09:50:16 -03:00
3d6548ce4a feat(entry): refine variant selection logic to exclude replaced variants and add unit tests for replacement behavior 2026-09-16 09:34:02 -03:00
8ad9b3a4e3 Revert "feat(variants): implement restoration of previously suspended variants with usable dates"
This reverts commit 3a4f6b64d2.
2026-09-16 09:08:43 -03:00
3a4f6b64d2 feat(variants): implement restoration of previously suspended variants with usable dates 2026-09-16 09:05:21 -03:00
44178d72a5 feat(variants): enhance disableForSuspension method to manage variant replacements and inventory merging 2026-09-16 08:56:01 -03:00
d02ad5fce2 feat(event): add per-user date change notices 2026-09-15 17:06:12 -03:00
69f2dbe056 refactor(event): remove date notices from tenant config 2026-09-15 17:06:06 -03:00
24d87623cc feat(event): implement EventDateGroupingService to group historical dates and update related resources and tests 2026-09-15 16:28:39 -03:00
51937ca93f feat(event): add EventDateInfoFormatter to format event date messages and implement related tests 2026-09-15 16:08:42 -03:00
dcf4383fbf feat(variants): implement inventory cloning for historical stock updates and adjust related tests 2026-09-15 15:15:18 -03:00
ff43ba32f3 feat(food): add endpoint to update historical stock and separate current and historical food variants 2026-09-15 15:03:41 -03:00
9fa4da7de2 feat(variants): add filtering for inactive event dates in visibleVariants method 2026-09-14 16:52:34 -03:00
c27d9628fd feat(event): filter event dates to exclude rescheduled and suspended entries 2026-09-14 16:48:06 -03:00
b1e09b71ad feat(migrations): implement backfill for legacy refunds and add migration tests 2026-09-14 15:13:16 -03:00
7d45734ad7 feat(forms): add entry form event dates 2026-09-14 15:00:29 -03:00
91022c5897 fix(forms): hide inactive food event dates 2026-09-14 15:00:23 -03:00
61314d5166 feat(storefront): expose event date notices 2026-09-14 14:49:30 -03:00
0d54887602 feat(event): audit suspended event dates 2026-09-14 14:19:29 -03:00
54827fbc58 feat(event): record date reschedules 2026-09-14 14:13:46 -03:00
18a0d14fa8 feat(event): add date reschedule history 2026-09-14 14:13:39 -03:00
b4a7b80430 test: cover event variant replacements 2026-09-14 14:11:24 -03:00
2bd3bf9dc1 feat(checkout): route carts to replacement variants 2026-09-14 14:09:49 -03:00
e42bc545a5 feat(event): replace variants when dates change 2026-09-14 14:09:42 -03:00
aa14129fe7 feat(catalog): track variant replacements 2026-09-14 14:07:55 -03:00
289ceba3af fix(tests): isolate databases in memory and reject persistent connections 2026-09-14 12:35:34 -03:00
28ab6c9ae4 fix(ticket): guard legacy refund amount removal 2026-09-14 12:31:03 -03:00
67deca095e refactor(ticket): use refunds as amount source 2026-09-14 12:29:13 -03:00
caed44fcc8 feat(ticket): persist and expose refund details 2026-09-14 11:57:31 -03:00
adec5d1725 feat(ticket): add refund transaction model 2026-09-14 11:57:06 -03:00
a62e989bb4 feat(ticket): enhance ticket name resolution to follow event date reschedules 2026-09-14 11:33:20 -03:00
9c14153199 feat(notification): implement idempotent email delivery system and update related services 2026-09-14 09:41:40 -03:00
756f4dad0a feat(notification): email event date changes 2026-09-14 08:53:51 -03:00
205d77bc0b feat(event): dispatch date change notifications 2026-09-14 08:53:45 -03:00
4f7ede1072 feat(purchase,sale,ticket): expose refunded total summary in admin sales and tickets 2026-09-11 16:23:23 -03:00
4bb4f526e4 feat(event): support ticket refund configuration in event endpoints 2026-09-11 16:22:46 -03:00
c0c19c9c01 feat(tenant): add master toggle for ticket refunds 2026-09-11 16:22:27 -03:00
b4da6e3747 feat(event): omit rescheduled and suspended dates from tenant resource 2026-09-11 16:21:37 -03:00
1880fc8147 feat(event): suspend event dates instead of cancelling 2026-09-11 15:57:17 -03:00
96df431d60 feat(event): implement admin date creation, rescheduling, and cancellation endpoints 2026-09-11 12:28:51 -03:00
106cf017dc feat(ticket): resolve effective event dates and handle reschedule chains 2026-09-11 12:28:37 -03:00
d5fdae9a24 feat(event): add rescheduling and cancellation state to event dates 2026-09-11 12:28:29 -03:00
4abb6c67fd feat(ticket): add refund calculation functionality and corresponding tests 2026-09-11 10:43:03 -03:00
6384c0046d feat(ticket): validate ticket capability status and use transaction in cancel and refund 2026-09-11 09:25:32 -03:00
2ddb046c26 feat(ticket): add action capability helpers and expose them in admin resource 2026-09-11 09:25:19 -03:00
5d00dc439e feat(ticket): implement refund functionality and expose allow_refund flag 2026-09-10 17:05:57 -03:00
210c854fee feat(tenant): add allow_refund and allow_partial_refund methods 2026-09-10 17:04:34 -03:00
beb5d18b29 feat(ticket): add cancel ticket functionality and corresponding tests 2026-09-10 16:28:45 -03:00
de259f4286 feat(ticket): implement terminal status management and validation 2026-09-10 16:20:35 -03:00
f19bca64d0 feat(admin): expose ticket refund states 2026-09-10 16:10:17 -03:00
18f739b712 feat(ticket): add refundable terminal states 2026-09-10 16:10:08 -03:00
1564985259 feat(tenant): expose ticket refund configuration 2026-09-10 15:29:41 -03:00
471a941587 feat(tenant): persist ticket refund configuration 2026-09-10 15:29:35 -03:00
4c647968cb Merge branch 'fix/admin_title' into dev 2026-09-09 16:30:00 -03:00
55dcc2e37f feat(website): add site_title to AdminAppBootstrapResource and related tests 2026-09-09 16:29:37 -03:00
cac4fcf2b2 feat(scanner): update scan handling to return results for invalid QR data and other scan conditions 2026-09-09 12:49:27 -03:00
64e965aff7 feat(scanner): update scan attempt result labels for consistency 2026-09-09 08:47:10 -03:00
002f08a8fa feat(staff): expose scanner attempt history 2026-09-08 10:56:27 -03:00
531dbe52b9 feat(scanner): expose scan result labels 2026-09-08 10:24:28 -03:00
a885a2fc0e feat(scanner): expose assigned scan categories 2026-09-08 10:01:18 -03:00
0c49bae752 feat(scanner): add scan attempt detail endpoint 2026-09-08 08:51:19 -03:00
67bcb420e4 feat(scanner): return scan attempt result context 2026-09-08 08:51:10 -03:00
34058e6a81 feat(scanner): implement scan attempt endpoint and update routes and tests 2026-09-07 16:56:12 -03:00
8a65d358a4 feat(scanner): implement scan attempt tracking with new request and resource classes 2026-09-07 16:55:52 -03:00
193e10dc48 feat(scan): implement scan attempt tracking and validation logic 2026-09-07 16:39:14 -03:00
b607c1b673 feat(scanner): refactor ticket scanning endpoint to accept UUID in request body and add validation 2026-09-07 16:18:16 -03:00
e9521e65d0 Merge branch 'refactor/integrations' into dev 2026-09-07 11:12:16 -03:00
d0625f25e5 refactor(integration): enhance association logic to delete unused instances and improve transaction handling 2026-09-07 11:09:08 -03:00
1fdcfc1547 Merge branch 'fix/sonder_bugs' into dev 2026-09-07 09:34:13 -03:00
ca4ea0aa6c feat(tenant): add asset URL to tenant resource response 2026-09-07 09:20:46 -03:00
bb1f9c8e91 Update postman collection 2026-09-04 16:46:40 -03:00
99fe94fa6a refactor(integration): streamline integration management by removing IntegrationInstanceController and updating related requests and services 2026-09-04 16:00:19 -03:00
a4b5c2eb19 refactor(auth): remove role-based logic from scanner authentication and permissions 2026-09-04 15:33:01 -03:00
2feca2bed5 docs(auth): explain email identity and role-specific flows 2026-09-04 14:17:42 -03:00
a35ff69140 feat(users): enforce active email uniqueness per role 2026-09-04 14:17:37 -03:00
75152b53a4 fix(auth): isolate password reset flows by application role 2026-09-04 14:17:27 -03:00
99594b17e7 fix(auth): resolve login identities by active email and role 2026-09-04 14:17:19 -03:00
85edea0661 docs(integration): explicar instancias, endpoints y despliegue
Documenta la prioridad cliente a tipo de sitio, la edicion compartida, los permisos y los contratos de la API. Aclara la migracion del requisito de configuracion y la asociacion directa requerida por el webhook de Telepagos.
2026-09-04 12:48:15 -03:00
ab5ea7dd33 feat(integration): administrar instancias y asociaciones con permisos
Agrega endpoints para crear, editar y asociar instancias a clientes y tipos de sitio. Valida las credenciales contra el esquema del catalogo y devuelve metadatos sin secretos.

Restringe la administracion al rol admin autenticado y mantiene inmutables los codigos de integracion. Incluye pruebas de API, permisos, prioridad de resolucion, edicion compartida y renovacion de tokens.
2026-09-04 12:48:06 -03:00
c0057c237a refactor(integration): resolver credenciales mediante instancias
Resuelve primero la instancia del cliente y luego la del tipo de sitio cuando existe contexto de tenant. Adapta SMTP y Telepagos, con tokens identificados por instancia y version de credenciales.

Separa la edicion de instancias de sus asociaciones y conserva el guardado por cliente creando una instancia nueva. Impide eliminar instancias en uso y adapta las pruebas existentes, incluida la herencia SMTP.
2026-09-04 12:47:47 -03:00
e4146288f1 refactor(integration): generalizar el requisito de configuracion
Renombra requires_client_configuration a requires_configuration conservando los valores existentes y su rollback. Actualiza el modelo, la validacion, los seeders y los consumidores del campo para permitir configuraciones de cliente o tipo de sitio.
2026-09-04 12:47:29 -03:00
f11ba5a470 feat(integration): add IntegrationInstance and WebsiteTypeIntegration models with migrations; refactor ClientIntegration and Integration models 2026-09-04 12:17:30 -03:00
239 changed files with 11595 additions and 985 deletions

View File

@@ -45,6 +45,7 @@ COMMANDS_LOG_LEVEL=info
COMMANDS_LOG_DAYS=30 COMMANDS_LOG_DAYS=30
EMAILS_LOG_LEVEL=info EMAILS_LOG_LEVEL=info
EMAILS_LOG_DAYS=30 EMAILS_LOG_DAYS=30
EMAIL_DELIVERY_LEASE_SECONDS=300
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1

View File

@@ -1,5 +1,11 @@
# Project Conventions # Project Conventions
## Test database safety
- Tests must use SQLite `:memory:` through `tests/bootstrap.php` and `Tests\TestCase`.
- Never run tests, `migrate:fresh`, `migrate:refresh`, or `db:wipe` against a persistent database, including the developer's `shopit` database.
- Never bypass the connection safety guard to resolve test failures. Use `php tests/verify-database-safety.php` to verify isolation without queries or migrations.
## Architecture ## Architecture
This project uses a domain-oriented structure under `app/Domains`. This project uses a domain-oriented structure under `app/Domains`.

View File

@@ -2,7 +2,7 @@
"info": { "info": {
"_postman_id": "76fd6fd2-53b9-4d92-8e02-1ddcc6207fa2", "_postman_id": "76fd6fd2-53b9-4d92-8e02-1ddcc6207fa2",
"name": "ShopIt API — Complete", "name": "ShopIt API — Complete",
"description": "Colección canónica generada desde las rutas reales de Laravel. Incluye 137 operaciones HTTP, ejemplos de payload, filtros, archivos y tokens separados para Storefront, Admin App y Scanner.\n\nUso rápido:\n1. Ajustá `base_url` y las credenciales.\n2. Ejecutá el Login de la aplicación correspondiente; el token se guarda automáticamente.\n3. Ajustá los IDs y códigos de las variables de colección.\n\nRegeneración: `php postman/generate-shopit-collection.php`.", "description": "Colección canónica generada desde las rutas reales de Laravel. Incluye 137 operaciones HTTP, presets de configuración para cada integración, ejemplos de payload, filtros, archivos y tokens separados para Storefront, Admin App y Scanner.\n\nUso rápido:\n1. Ajustá `base_url` y las credenciales.\n2. Ejecutá el Login de la aplicación correspondiente; el token se guarda automáticamente.\n3. Ajustá los IDs y códigos de las variables de colección.\n\nRegeneración: `php postman/generate-shopit-collection.php`.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
}, },
"item": [ "item": [
@@ -1249,7 +1249,7 @@
"name": "Ticket", "name": "Ticket",
"item": [ "item": [
{ {
"name": "List Ticket", "name": "List Scan Attempt",
"request": { "request": {
"method": "GET", "method": "GET",
"header": [ "header": [
@@ -1425,14 +1425,14 @@
], ],
"description": "Ruta Laravel: `GET /api/clients/{client}`\n\nControlador: `App\\Domains\\Client\\Controllers\\ClientController@show`", "description": "Ruta Laravel: `GET /api/clients/{client}`\n\nControlador: `App\\Domains\\Client\\Controllers\\ClientController@show`",
"url": { "url": {
"raw": "{{base_url}}/api/clients/{{client_id}}", "raw": "{{base_url}}/api/clients/{{client_code}}",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
"path": [ "path": [
"api", "api",
"clients", "clients",
"{{client_id}}" "{{client_code}}"
] ]
} }
}, },
@@ -1456,14 +1456,14 @@
], ],
"description": "Ruta Laravel: `PUT /api/clients/{client}`\n\nControlador: `App\\Domains\\Client\\Controllers\\ClientController@update`", "description": "Ruta Laravel: `PUT /api/clients/{client}`\n\nControlador: `App\\Domains\\Client\\Controllers\\ClientController@update`",
"url": { "url": {
"raw": "{{base_url}}/api/clients/{{client_id}}", "raw": "{{base_url}}/api/clients/{{client_code}}",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
"path": [ "path": [
"api", "api",
"clients", "clients",
"{{client_id}}" "{{client_code}}"
] ]
}, },
"body": { "body": {
@@ -1496,14 +1496,14 @@
], ],
"description": "Ruta Laravel: `PATCH /api/clients/{client}`\n\nControlador: `App\\Domains\\Client\\Controllers\\ClientController@update`", "description": "Ruta Laravel: `PATCH /api/clients/{client}`\n\nControlador: `App\\Domains\\Client\\Controllers\\ClientController@update`",
"url": { "url": {
"raw": "{{base_url}}/api/clients/{{client_id}}", "raw": "{{base_url}}/api/clients/{{client_code}}",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
"path": [ "path": [
"api", "api",
"clients", "clients",
"{{client_id}}" "{{client_code}}"
] ]
}, },
"body": { "body": {
@@ -1531,14 +1531,14 @@
], ],
"description": "Ruta Laravel: `DELETE /api/clients/{client}`\n\nControlador: `App\\Domains\\Client\\Controllers\\ClientController@destroy`", "description": "Ruta Laravel: `DELETE /api/clients/{client}`\n\nControlador: `App\\Domains\\Client\\Controllers\\ClientController@destroy`",
"url": { "url": {
"raw": "{{base_url}}/api/clients/{{client_id}}", "raw": "{{base_url}}/api/clients/{{client_code}}",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
"path": [ "path": [
"api", "api",
"clients", "clients",
"{{client_id}}" "{{client_code}}"
] ]
} }
}, },
@@ -1548,6 +1548,13 @@
}, },
{ {
"name": "Integration", "name": "Integration",
"item": [
{
"name": "Client",
"item": [
{
"name": "Management",
"description": "Consulta, configura o desvincula cualquier integración usando `{{integration_code}}`.",
"item": [ "item": [
{ {
"name": "List Client Integration", "name": "List Client Integration",
@@ -1560,18 +1567,28 @@
"type": "text" "type": "text"
} }
], ],
"description": "Ruta Laravel: `GET /api/clients/{client}/integrations`\n\nControlador: `App\\Domains\\Integration\\Controllers\\ClientIntegrationController@index`", "description": "Ruta Laravel: `GET /api/clients/{client}/integrations`\n\nControlador: `App\\Domains\\Integration\\Controllers\\ClientIntegrationController@index`\n\nRequiere autenticación Sanctum.",
"url": { "url": {
"raw": "{{base_url}}/api/clients/{{client_id}}/integrations", "raw": "{{base_url}}/api/clients/{{client_code}}/integrations",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
"path": [ "path": [
"api", "api",
"clients", "clients",
"{{client_id}}", "{{client_code}}",
"integrations" "integrations"
] ]
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
} }
}, },
"response": [] "response": []
@@ -1587,25 +1604,35 @@
"type": "text" "type": "text"
} }
], ],
"description": "Ruta Laravel: `GET /api/clients/{client}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\ClientIntegrationController@show`", "description": "Ruta Laravel: `GET /api/clients/{client}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\ClientIntegrationController@show`\n\nRequiere autenticación Sanctum.",
"url": { "url": {
"raw": "{{base_url}}/api/clients/{{client_id}}/integrations/{{integration_code}}", "raw": "{{base_url}}/api/clients/{{client_code}}/integrations/{{integration_code}}",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
"path": [ "path": [
"api", "api",
"clients", "clients",
"{{client_id}}", "{{client_code}}",
"integrations", "integrations",
"{{integration_code}}" "{{integration_code}}"
] ]
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
} }
}, },
"response": [] "response": []
}, },
{ {
"name": "Create Client Integration", "name": "Configure Integration (generic)",
"request": { "request": {
"method": "PUT", "method": "PUT",
"header": [ "header": [
@@ -1620,16 +1647,16 @@
"type": "text" "type": "text"
} }
], ],
"description": "Ruta Laravel: `PUT /api/clients/{client}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\ClientIntegrationController@store`", "description": "Ruta Laravel: `PUT /api/clients/{client}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\ClientIntegrationController@store`\n\nRequiere autenticación Sanctum.",
"url": { "url": {
"raw": "{{base_url}}/api/clients/{{client_id}}/integrations/{{integration_code}}", "raw": "{{base_url}}/api/clients/{{client_code}}/integrations/{{integration_code}}",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
"path": [ "path": [
"api", "api",
"clients", "clients",
"{{client_id}}", "{{client_code}}",
"integrations", "integrations",
"{{integration_code}}" "{{integration_code}}"
] ]
@@ -1642,78 +1669,24 @@
"language": "json" "language": "json"
} }
} }
}
},
"response": []
}, },
"auth": {
"type": "bearer",
"bearer": [
{ {
"name": "List Integration", "key": "token",
"request": { "value": "{{token}}",
"method": "GET", "type": "string"
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
} }
],
"description": "Ruta Laravel: `GET /api/integrations`\n\nControlador: `App\\Domains\\Integration\\Controllers\\IntegrationController@index`",
"url": {
"raw": "{{base_url}}/api/integrations",
"host": [
"{{base_url}}"
],
"path": [
"api",
"integrations"
] ]
} }
}, },
"response": [] "response": []
}, },
{ {
"name": "Create Integration", "name": "Delete Client Integration",
"request": { "request": {
"method": "POST", "method": "DELETE",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"description": "Ruta Laravel: `POST /api/integrations`\n\nControlador: `App\\Domains\\Integration\\Controllers\\IntegrationController@store`",
"url": {
"raw": "{{base_url}}/api/integrations",
"host": [
"{{base_url}}"
],
"path": [
"api",
"integrations"
]
},
"body": {
"mode": "raw",
"raw": "{\n \"integration_code\": \"telepagos\",\n \"name\": \"Telepagos\",\n \"url\": \"https://api.example.com\",\n \"integration_data_schema\": {\n \"api_key\": [\n \"required\",\n \"string\"\n ]\n },\n \"requires_client_configuration\": true\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
},
{
"name": "Get Integration",
"request": {
"method": "GET",
"header": [ "header": [
{ {
"key": "Accept", "key": "Accept",
@@ -1721,23 +1694,41 @@
"type": "text" "type": "text"
} }
], ],
"description": "Ruta Laravel: `GET /api/integrations/{integration}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\IntegrationController@show`", "description": "Ruta Laravel: `DELETE /api/clients/{client}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\ClientIntegrationController@destroy`\n\nRequiere autenticación Sanctum.",
"url": { "url": {
"raw": "{{base_url}}/api/integrations/{{integration_id}}", "raw": "{{base_url}}/api/clients/{{client_code}}/integrations/{{integration_code}}",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
"path": [ "path": [
"api", "api",
"clients",
"{{client_code}}",
"integrations", "integrations",
"{{integration_id}}" "{{integration_code}}"
]
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
] ]
} }
}, },
"response": [] "response": []
}
]
}, },
{ {
"name": "Update Integration", "name": "Email (SMTP)",
"description": "Configura el transporte SMTP usado para el envío de correos.",
"item": [
{
"name": "Configure Email (SMTP)",
"request": { "request": {
"method": "PUT", "method": "PUT",
"header": [ "header": [
@@ -1752,32 +1743,298 @@
"type": "text" "type": "text"
} }
], ],
"description": "Ruta Laravel: `PUT /api/integrations/{integration}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\IntegrationController@update`", "description": "Ruta Laravel: `PUT /api/clients/{client}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\ClientIntegrationController@store`\n\nRequiere autenticación Sanctum.\n\nPreset: `email`. Configura el transporte SMTP usado para el envío de correos.",
"url": { "url": {
"raw": "{{base_url}}/api/integrations/{{integration_id}}", "raw": "{{base_url}}/api/clients/{{client_code}}/integrations/email",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
"path": [ "path": [
"api", "api",
"clients",
"{{client_code}}",
"integrations", "integrations",
"{{integration_id}}" "email"
] ]
}, },
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"name\": \"Telepagos\",\n \"url\": \"https://api.example.com\",\n \"requires_client_configuration\": true\n}", "raw": "{\n \"integration_data\": {\n \"MAIL_MAILER\": \"smtp\",\n \"MAIL_SCHEME\": \"smtp\",\n \"MAIL_HOST\": \"smtp.example.com\",\n \"MAIL_PORT\": 587,\n \"MAIL_USERNAME\": \"usuario@example.com\",\n \"MAIL_PASSWORD\": \"replace-me\",\n \"MAIL_FROM_ADDRESS\": \"no-reply@example.com\",\n \"MAIL_FROM_NAME\": \"ShopIt\"\n }\n}",
"options": { "options": {
"raw": { "raw": {
"language": "json" "language": "json"
} }
} }
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
}
},
"response": []
}
]
},
{
"name": "Telepagos Producción",
"description": "Configura las credenciales productivas de Telepagos.",
"item": [
{
"name": "Configure Telepagos Producción",
"request": {
"method": "PUT",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"description": "Ruta Laravel: `PUT /api/clients/{client}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\ClientIntegrationController@store`\n\nRequiere autenticación Sanctum.\n\nPreset: `telepagos`. Configura las credenciales productivas de Telepagos.",
"url": {
"raw": "{{base_url}}/api/clients/{{client_code}}/integrations/telepagos",
"host": [
"{{base_url}}"
],
"path": [
"api",
"clients",
"{{client_code}}",
"integrations",
"telepagos"
]
},
"body": {
"mode": "raw",
"raw": "{\n \"integration_data\": {\n \"username\": \"replace-me\",\n \"password\": \"replace-me\"\n }\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
}
},
"response": []
}
]
},
{
"name": "Telepagos Homologación",
"description": "Configura las credenciales del entorno de homologación de Telepagos.",
"item": [
{
"name": "Configure Telepagos Homologación",
"request": {
"method": "PUT",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"description": "Ruta Laravel: `PUT /api/clients/{client}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\ClientIntegrationController@store`\n\nRequiere autenticación Sanctum.\n\nPreset: `telepagos_homo`. Configura las credenciales del entorno de homologación de Telepagos.",
"url": {
"raw": "{{base_url}}/api/clients/{{client_code}}/integrations/telepagos_homo",
"host": [
"{{base_url}}"
],
"path": [
"api",
"clients",
"{{client_code}}",
"integrations",
"telepagos_homo"
]
},
"body": {
"mode": "raw",
"raw": "{\n \"integration_data\": {\n \"username\": \"replace-me\",\n \"password\": \"replace-me\"\n }\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
}
},
"response": []
}
]
}
]
},
{
"name": "Website Type",
"item": [
{
"name": "Management",
"description": "Consulta, configura o desvincula cualquier integración usando `{{integration_code}}`.",
"item": [
{
"name": "List Website Type Integration",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"description": "Ruta Laravel: `GET /api/website-types/{websiteType:codigo}/integrations`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@index`\n\nRequiere autenticación Sanctum.",
"url": {
"raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations",
"host": [
"{{base_url}}"
],
"path": [
"api",
"website-types",
"{{website_type_code}}",
"integrations"
]
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
} }
}, },
"response": [] "response": []
}, },
{ {
"name": "Delete Integration", "name": "Get Website Type Integration",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"description": "Ruta Laravel: `GET /api/website-types/{websiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@show`\n\nRequiere autenticación Sanctum.",
"url": {
"raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations/{{integration_code}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"website-types",
"{{website_type_code}}",
"integrations",
"{{integration_code}}"
]
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
}
},
"response": []
},
{
"name": "Configure Integration (generic)",
"request": {
"method": "PUT",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"description": "Ruta Laravel: `PUT /api/website-types/{websiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@store`\n\nRequiere autenticación Sanctum.",
"url": {
"raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations/{{integration_code}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"website-types",
"{{website_type_code}}",
"integrations",
"{{integration_code}}"
]
},
"body": {
"mode": "raw",
"raw": "{\n \"integration_data\": {\n \"api_key\": \"replace-me\"\n }\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
}
},
"response": []
},
{
"name": "Delete Website Type Integration",
"request": { "request": {
"method": "DELETE", "method": "DELETE",
"header": [ "header": [
@@ -1787,16 +2044,28 @@
"type": "text" "type": "text"
} }
], ],
"description": "Ruta Laravel: `DELETE /api/integrations/{integration}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\IntegrationController@destroy`", "description": "Ruta Laravel: `DELETE /api/website-types/{websiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@destroy`\n\nRequiere autenticación Sanctum.",
"url": { "url": {
"raw": "{{base_url}}/api/integrations/{{integration_id}}", "raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations/{{integration_code}}",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
"path": [ "path": [
"api", "api",
"website-types",
"{{website_type_code}}",
"integrations", "integrations",
"{{integration_id}}" "{{integration_code}}"
]
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
] ]
} }
}, },
@@ -1804,6 +2073,184 @@
} }
] ]
}, },
{
"name": "Email (SMTP)",
"description": "Configura el transporte SMTP usado para el envío de correos.",
"item": [
{
"name": "Configure Email (SMTP)",
"request": {
"method": "PUT",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"description": "Ruta Laravel: `PUT /api/website-types/{websiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@store`\n\nRequiere autenticación Sanctum.\n\nPreset: `email`. Configura el transporte SMTP usado para el envío de correos.",
"url": {
"raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations/email",
"host": [
"{{base_url}}"
],
"path": [
"api",
"website-types",
"{{website_type_code}}",
"integrations",
"email"
]
},
"body": {
"mode": "raw",
"raw": "{\n \"integration_data\": {\n \"MAIL_MAILER\": \"smtp\",\n \"MAIL_SCHEME\": \"smtp\",\n \"MAIL_HOST\": \"smtp.example.com\",\n \"MAIL_PORT\": 587,\n \"MAIL_USERNAME\": \"usuario@example.com\",\n \"MAIL_PASSWORD\": \"replace-me\",\n \"MAIL_FROM_ADDRESS\": \"no-reply@example.com\",\n \"MAIL_FROM_NAME\": \"ShopIt\"\n }\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
}
},
"response": []
}
]
},
{
"name": "Telepagos Producción",
"description": "Configura las credenciales productivas de Telepagos.",
"item": [
{
"name": "Configure Telepagos Producción",
"request": {
"method": "PUT",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"description": "Ruta Laravel: `PUT /api/website-types/{websiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@store`\n\nRequiere autenticación Sanctum.\n\nPreset: `telepagos`. Configura las credenciales productivas de Telepagos.",
"url": {
"raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations/telepagos",
"host": [
"{{base_url}}"
],
"path": [
"api",
"website-types",
"{{website_type_code}}",
"integrations",
"telepagos"
]
},
"body": {
"mode": "raw",
"raw": "{\n \"integration_data\": {\n \"username\": \"replace-me\",\n \"password\": \"replace-me\"\n }\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
}
},
"response": []
}
]
},
{
"name": "Telepagos Homologación",
"description": "Configura las credenciales del entorno de homologación de Telepagos.",
"item": [
{
"name": "Configure Telepagos Homologación",
"request": {
"method": "PUT",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"description": "Ruta Laravel: `PUT /api/website-types/{websiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@store`\n\nRequiere autenticación Sanctum.\n\nPreset: `telepagos_homo`. Configura las credenciales del entorno de homologación de Telepagos.",
"url": {
"raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations/telepagos_homo",
"host": [
"{{base_url}}"
],
"path": [
"api",
"website-types",
"{{website_type_code}}",
"integrations",
"telepagos_homo"
]
},
"body": {
"mode": "raw",
"raw": "{\n \"integration_data\": {\n \"username\": \"replace-me\",\n \"password\": \"replace-me\"\n }\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
}
},
"response": []
}
]
}
]
}
]
},
{ {
"name": "Menu", "name": "Menu",
"item": [ "item": [
@@ -5551,9 +5998,9 @@
"type": "text" "type": "text"
} }
], ],
"description": "Ruta Laravel: `GET /api/v1/scanner/tickets`\n\nControlador: `App\\Domains\\Ticket\\Controllers\\Scanner\\TicketController@index`\n\nRequiere autenticación Sanctum.", "description": "Ruta Laravel: `GET /api/v1/scanner/attempts`\n\nControlador: `App\\Domains\\Ticket\\Controllers\\Scanner\\ScanAttemptController`\n\nRequiere autenticación Sanctum.",
"url": { "url": {
"raw": "{{base_url}}/api/v1/scanner/tickets?page=1&per_page=20", "raw": "{{base_url}}/api/v1/scanner/attempts?page=1&per_page=20",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
@@ -5561,7 +6008,7 @@
"api", "api",
"v1", "v1",
"scanner", "scanner",
"tickets" "attempts"
], ],
"query": [ "query": [
{ {
@@ -5639,11 +6086,16 @@
"key": "Accept", "key": "Accept",
"value": "application/json", "value": "application/json",
"type": "text" "type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
} }
], ],
"description": "Ruta Laravel: `POST /api/v1/scanner/tickets/{ticketUuid}/scan`\n\nControlador: `App\\Domains\\Ticket\\Controllers\\Scanner\\TicketController@scan`\n\nRequiere autenticación Sanctum.", "description": "Ruta Laravel: `POST /api/v1/scanner/tickets/scan`\n\nControlador: `App\\Domains\\Ticket\\Controllers\\Scanner\\TicketController@scan`\n\nRequiere autenticación Sanctum.",
"url": { "url": {
"raw": "{{base_url}}/api/v1/scanner/tickets/{{ticket_uuid}}/scan", "raw": "{{base_url}}/api/v1/scanner/tickets/scan",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
@@ -5652,10 +6104,18 @@
"v1", "v1",
"scanner", "scanner",
"tickets", "tickets",
"{{ticket_uuid}}",
"scan" "scan"
] ]
}, },
"body": {
"mode": "raw",
"raw": "{\n \"data\": \"{{ticket_uuid}}\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"auth": { "auth": {
"type": "bearer", "type": "bearer",
"bearer": [ "bearer": [
@@ -5698,7 +6158,7 @@
], ],
"description": "Ruta Laravel: `POST /api/webhooks/telepagos/{client}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\TelepagosWebhookController@handle`", "description": "Ruta Laravel: `POST /api/webhooks/telepagos/{client}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\TelepagosWebhookController@handle`",
"url": { "url": {
"raw": "{{base_url}}/api/webhooks/telepagos/{{client_id}}", "raw": "{{base_url}}/api/webhooks/telepagos/{{client_code}}",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
@@ -5706,7 +6166,7 @@
"api", "api",
"webhooks", "webhooks",
"telepagos", "telepagos",
"{{client_id}}" "{{client_code}}"
] ]
}, },
"body": { "body": {
@@ -5803,8 +6263,13 @@
"type": "string" "type": "string"
}, },
{ {
"key": "integration_id", "key": "client_code",
"value": "1", "value": "cliente-demo",
"type": "string"
},
{
"key": "website_type_code",
"value": "shopit",
"type": "string" "type": "string"
}, },
{ {

View File

@@ -31,7 +31,7 @@ class StoreAdministratorRequest extends FormRequest
'required', 'required',
'email', 'email',
'max:255', 'max:255',
Rule::unique('users', 'email')->whereNull('deleted_at'), Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::AdminApp->value)->whereNull('deleted_at'),
], ],
]; ];
} }

View File

@@ -32,7 +32,7 @@ class UpdateAdministratorRequest extends FormRequest
'required', 'required',
'email', 'email',
'max:255', 'max:255',
Rule::unique('users', 'email') Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::AdminApp->value)
->whereNull('deleted_at') ->whereNull('deleted_at')
->ignore($administratorId), ->ignore($administratorId),
], ],

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\ResetPasswordAttempt; use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Requests\ResetPasswordRequest; use App\Domains\Auth\Requests\ResetPasswordRequest;
use App\Domains\Auth\Services\ResetPasswordAttemptService; use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Domains\Authorization\Enums\RoleCode;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -26,6 +27,7 @@ class ResetPasswordController extends Controller
$data['email'], $data['email'],
$data['codigo'], $data['codigo'],
$data['password'], $data['password'],
RoleCode::from($request->route('reset_role', RoleCode::User->value)),
)) { )) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'codigo' => __('api.auth.password_reset_invalid'), 'codigo' => __('api.auth.password_reset_invalid'),

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\ResetPasswordAttempt; use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Requests\ValidateResetPasswordAttemptRequest; use App\Domains\Auth\Requests\ValidateResetPasswordAttemptRequest;
use App\Domains\Auth\Services\ResetPasswordAttemptService; use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Domains\Authorization\Enums\RoleCode;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -25,6 +26,7 @@ class ValidateResetPasswordAttemptController extends Controller
$result = $this->resetPasswordAttemptService->validateCode( $result = $this->resetPasswordAttemptService->validateCode(
$data['email'], $data['email'],
$data['codigo'], $data['codigo'],
RoleCode::from($request->route('reset_role', RoleCode::User->value)),
); );
if ($result === ResetPasswordAttemptService::CODE_EXPIRED) { if ($result === ResetPasswordAttemptService::CODE_EXPIRED) {

View File

@@ -5,7 +5,9 @@ namespace App\Domains\Auth\Models;
use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Role; use App\Domains\Authorization\Models\Role;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Event\Models\EventDateChangeView;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\ScanAttempt;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\Hidden;
@@ -46,6 +48,18 @@ class User extends Authenticatable
return $this->hasMany(LoginAttempt::class); return $this->hasMany(LoginAttempt::class);
} }
/** @return HasMany<ScanAttempt, $this> */
public function scanAttempts(): HasMany
{
return $this->hasMany(ScanAttempt::class, 'scanner_user_id');
}
/** @return HasMany<EventDateChangeView, $this> */
public function eventDateChangeViews(): HasMany
{
return $this->hasMany(EventDateChangeView::class);
}
/** /**
* @return BelongsTo<Role, $this> * @return BelongsTo<Role, $this>
*/ */

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Auth\Requests; namespace App\Domains\Auth\Requests;
use App\Domains\Authorization\Enums\RoleCode;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
@@ -13,9 +14,14 @@ class RegisterUserRequest extends FormRequest
return true; return true;
} }
/** protected function prepareForValidation(): void
* @return array<string, mixed> {
*/ if (is_string($this->input('email'))) {
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
}
}
/** @return array<string, mixed> */
public function rules(): array public function rules(): array
{ {
return [ return [
@@ -26,7 +32,7 @@ class RegisterUserRequest extends FormRequest
'string', 'string',
'email', 'email',
'max:255', 'max:255',
Rule::unique('users', 'email')->whereNull('deleted_at'), Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::User->value)->whereNull('deleted_at'),
], ],
'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()], 'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()],
'dni' => ['nullable', 'string', 'max:255'], 'dni' => ['nullable', 'string', 'max:255'],

View File

@@ -13,6 +13,13 @@ class UpdateProfileRequest extends FormRequest
return true; return true;
} }
protected function prepareForValidation(): void
{
if (is_string($this->input('email'))) {
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
}
}
public function rules(): array public function rules(): array
{ {
return [ return [
@@ -20,7 +27,7 @@ class UpdateProfileRequest extends FormRequest
'email' => [ 'email' => [
'required', 'required',
'email', 'email',
Rule::unique('users', 'email') Rule::unique('users', 'active_email')->where('rol_codigo', $this->user()->rol_codigo)
->whereNull('deleted_at') ->whereNull('deleted_at')
->ignore($this->user()->id), ->ignore($this->user()->id),
], ],

View File

@@ -24,6 +24,11 @@ class UserResource extends JsonResource
'telefono' => $this->telefono, 'telefono' => $this->telefono,
'rol_codigo' => $this->rol_codigo, 'rol_codigo' => $this->rol_codigo,
'tenant_codigo' => $this->tenant_codigo, 'tenant_codigo' => $this->tenant_codigo,
'categories' => $this->whenLoaded('scanCategories', fn () => $this->scanCategories
->map(fn ($category) => [
'id' => $category->id,
'nombre' => $category->nombre,
])->values()),
]; ];
} }
} }

View File

@@ -11,7 +11,7 @@ class AdminCredentialVerifier
public function verify(string $email, string $password): bool public function verify(string $email, string $password): bool
{ {
$admin = User::query() $admin = User::query()
->where('email', mb_strtolower(trim($email))) ->where('active_email', mb_strtolower(trim($email)))
->where('rol_codigo', RoleCode::Admin->value) ->where('rol_codigo', RoleCode::Admin->value)
->first(); ->first();

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Auth\Services; namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Notification\Events\UserRegistered; use App\Domains\Notification\Events\UserRegistered;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Support\TenantDomainNormalizer; use App\Domains\Tenant\Support\TenantDomainNormalizer;
@@ -124,12 +125,12 @@ class GoogleAuthService
]); ]);
} }
$user = User::query()->where('google_id', $googleId)->first(); $user = User::query()->where('rol_codigo', RoleCode::User->value)->where('google_id', $googleId)->first();
if ($user) { if ($user) {
return $user; return $user;
} }
$user = User::query()->where('email', $email)->first(); $user = User::query()->where('rol_codigo', RoleCode::User->value)->where('active_email', mb_strtolower(trim($email)))->first();
if ($user) { if ($user) {
$user->forceFill(['google_id' => $googleId])->save(); $user->forceFill(['google_id' => $googleId])->save();

View File

@@ -85,7 +85,7 @@ class PasswordLoginService
null, null,
$ipAddress, $ipAddress,
$userAgent, $userAgent,
null, RoleCode::Scanner,
true, true,
PermissionCode::ScanTickets->value, PermissionCode::ScanTickets->value,
PasswordResetRequested::CHANNEL_SCANNER, PasswordResetRequested::CHANNEL_SCANNER,
@@ -120,7 +120,7 @@ class PasswordLoginService
$passwordResetChannel, $passwordResetChannel,
): array { ): array {
$user = User::query() $user = User::query()
->where('email', $normalizedEmail) ->where('active_email', $normalizedEmail)
->when( ->when(
$requiredRole !== null, $requiredRole !== null,
fn ($query) => $query->where('rol_codigo', $requiredRole->value), fn ($query) => $query->where('rol_codigo', $requiredRole->value),

View File

@@ -28,7 +28,8 @@ class ResetPasswordAttemptService
try { try {
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int { $attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
$user = User::query() $user = User::query()
->where('email', $email) ->where('active_email', mb_strtolower(trim($email)))
->where('rol_codigo', RoleCode::User->value)
->lockForUpdate() ->lockForUpdate()
->first(); ->first();
@@ -65,7 +66,7 @@ class ResetPasswordAttemptService
try { try {
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array { $result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
$user = User::query() $user = User::query()
->where('email', $email) ->where('active_email', mb_strtolower(trim($email)))
->where('rol_codigo', RoleCode::AdminApp->value) ->where('rol_codigo', RoleCode::AdminApp->value)
->whereNotNull('tenant_codigo') ->whereNotNull('tenant_codigo')
->lockForUpdate() ->lockForUpdate()
@@ -113,7 +114,7 @@ class ResetPasswordAttemptService
try { try {
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array { $result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
$user = User::query() $user = User::query()
->where('email', $email) ->where('active_email', mb_strtolower(trim($email)))
->where('rol_codigo', RoleCode::Scanner->value) ->where('rol_codigo', RoleCode::Scanner->value)
->whereNotNull('tenant_codigo') ->whereNotNull('tenant_codigo')
->lockForUpdate() ->lockForUpdate()
@@ -152,14 +153,15 @@ class ResetPasswordAttemptService
); );
} }
public function validateCode(string $email, string $code): string public function validateCode(string $email, string $code, RoleCode $role = RoleCode::User): string
{ {
$emailFingerprint = $this->emailFingerprint($email); $emailFingerprint = $this->emailFingerprint($email);
try { try {
return DB::transaction(function () use ($email, $code, $emailFingerprint): string { return DB::transaction(function () use ($email, $code, $emailFingerprint, $role): string {
$user = User::query() $user = User::query()
->where('email', $email) ->where('active_email', mb_strtolower(trim($email)))
->where('rol_codigo', $role->value)
->lockForUpdate() ->lockForUpdate()
->first(); ->first();
@@ -207,14 +209,15 @@ class ResetPasswordAttemptService
} }
} }
public function resetPassword(string $email, string $code, string $password): bool public function resetPassword(string $email, string $code, string $password, RoleCode $role = RoleCode::User): bool
{ {
$emailFingerprint = $this->emailFingerprint($email); $emailFingerprint = $this->emailFingerprint($email);
try { try {
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint): bool { return DB::transaction(function () use ($email, $code, $password, $emailFingerprint, $role): bool {
$user = User::query() $user = User::query()
->where('email', $email) ->where('active_email', mb_strtolower(trim($email)))
->where('rol_codigo', $role->value)
->lockForUpdate() ->lockForUpdate()
->first(); ->first();

View File

@@ -19,6 +19,16 @@ class ScannerContextService
$user->setRelation('tenant', $tenant); $user->setRelation('tenant', $tenant);
if ($tenant->requiresScannerCategoryValidation()) {
$categories = $user->scanCategories()
->orderBy('nombre')
->get();
if ($categories->isNotEmpty()) {
$user->setRelation('scanCategories', $categories);
}
}
return $user; return $user;
} }
} }

View File

@@ -12,8 +12,10 @@ Route::prefix('v1/adminapp')->group(function (): void {
Route::post('password/reset-attempts', CreateAdminAppResetPasswordAttemptController::class) Route::post('password/reset-attempts', CreateAdminAppResetPasswordAttemptController::class)
->middleware('throttle:5,1'); ->middleware('throttle:5,1');
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class) Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
->defaults('reset_role', 'adminapp')
->middleware('throttle:10,1'); ->middleware('throttle:10,1');
Route::post('password/reset', ResetPasswordController::class) Route::post('password/reset', ResetPasswordController::class)
->defaults('reset_role', 'adminapp')
->middleware('throttle:5,1'); ->middleware('throttle:5,1');
Route::middleware(['auth:sanctum', 'adminapp.tenant']) Route::middleware(['auth:sanctum', 'adminapp.tenant'])
->get('me', AdminAppMeController::class); ->get('me', AdminAppMeController::class);

View File

@@ -12,8 +12,10 @@ Route::prefix('v1/scanner')->group(function (): void {
Route::post('password/reset-attempts', CreateScannerResetPasswordAttemptController::class) Route::post('password/reset-attempts', CreateScannerResetPasswordAttemptController::class)
->middleware('throttle:5,1'); ->middleware('throttle:5,1');
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class) Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
->defaults('reset_role', 'scanner')
->middleware('throttle:10,1'); ->middleware('throttle:10,1');
Route::post('password/reset', ResetPasswordController::class) Route::post('password/reset', ResetPasswordController::class)
->defaults('reset_role', 'scanner')
->middleware('throttle:5,1'); ->middleware('throttle:5,1');
Route::middleware(['auth:sanctum', 'scanner.tenant']) Route::middleware(['auth:sanctum', 'scanner.tenant'])
->get('me', ScannerMeController::class); ->get('me', ScannerMeController::class);

View File

@@ -17,6 +17,7 @@ class AdminAppBootstrapResource extends JsonResource
return [ return [
'website_type_code' => $websiteType->codigo, 'website_type_code' => $websiteType->codigo,
'site_title' => $websiteType->site_title,
'primary_color' => $websiteType->primary_color, 'primary_color' => $websiteType->primary_color,
'secondary_color' => $websiteType->secondary_color, 'secondary_color' => $websiteType->secondary_color,
'danger_color' => $websiteType->danger_color, 'danger_color' => $websiteType->danger_color,

View File

@@ -32,6 +32,7 @@ class TenantBootstrapService
return $this->tenantInformationService->load( return $this->tenantInformationService->load(
$tenant, $tenant,
[ [
'eventDateChanges',
'menues' => fn ($query) => $query->whereHas( 'menues' => fn ($query) => $query->whereHas(
'roles', 'roles',
fn ($query) => $query->where('codigo', RoleCode::User->value) fn ($query) => $query->where('codigo', RoleCode::User->value)

View File

@@ -400,6 +400,17 @@ class Cart extends Model
]); ]);
} }
if ($catalogItem->bundleComponents()
->whereNotNull('component_variant_id')
->whereHas('variant', fn ($query) => $query
->whereNotNull('sales_disabled_at')
->orWhereNotNull('replaced_by_variant_id'))
->exists()) {
throw ValidationException::withMessages([
'catalog_item_id' => [__('api.cart.bundle_component_unavailable')],
]);
}
return $catalogItem; return $catalogItem;
} }
@@ -430,6 +441,12 @@ class Cart extends Model
throw new NotFoundHttpException('Variant not found for catalog item.'); throw new NotFoundHttpException('Variant not found for catalog item.');
} }
if (! $variant->isSellable()) {
throw ValidationException::withMessages([
'variant_id' => [__('api.cart.variant_unavailable')],
]);
}
$inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate); $inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate);
$variant->setRelation('catalogItem', $catalogItem); $variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation('inventory', $inventory); $variant->setRelation('inventory', $inventory);

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Domains\Cart\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use Illuminate\Validation\ValidationException;
class CartVariantReplacementService
{
public function __construct(private readonly CatalogInventoryService $inventory) {}
public function replaceHistoricalVariants(Cart $cart): void
{
$items = $cart->items()
->whereNotNull('variant_id')
->orderBy('id')
->lockForUpdate()
->get();
foreach ($items as $item) {
$variant = Variant::query()->lockForUpdate()->find($item->variant_id);
if ($variant === null) {
throw $this->unavailableVariant();
}
$replacement = $this->latestReplacement($variant);
if ($replacement->is($variant)) {
if (! $variant->isSellable()) {
throw $this->unavailableVariant();
}
continue;
}
if (! $replacement->isSellable()) {
throw $this->unavailableVariant();
}
/** @var CartItem|null $targetItem */
$targetItem = $cart->items()
->whereKeyNot($item->getKey())
->where('catalog_item_id', $item->catalog_item_id)
->where('variant_id', $replacement->getKey())
->lockForUpdate()
->first();
$replacementQuantity = $item->cantidad + ($targetItem?->cantidad ?? 0);
if ($replacement->inventory_id !== $variant->inventory_id) {
$available = $this->inventory->availableQuantity($replacement);
if ($available !== null && $available < $replacementQuantity) {
throw $this->unavailableVariant();
}
}
if ($targetItem !== null) {
$targetItem->cantidad += $item->cantidad;
$targetItem->save();
$item->delete();
continue;
}
$item->update(['variant_id' => $replacement->getKey()]);
}
}
private function latestReplacement(Variant $variant): Variant
{
$current = $variant;
$visited = [];
while ($current->replaced_by_variant_id !== null) {
if (isset($visited[$current->getKey()])) {
throw $this->unavailableVariant();
}
$visited[$current->getKey()] = true;
$current = Variant::query()
->lockForUpdate()
->find($current->replaced_by_variant_id)
?? throw $this->unavailableVariant();
}
return $current;
}
private function unavailableVariant(): ValidationException
{
return ValidationException::withMessages([
'cart_id' => [__('api.cart.cart_variant_unavailable')],
]);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Domains\Cart\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class InvalidateEventDateCartsService
{
public function __construct(private readonly StockReservationService $reservations) {}
/** @param Collection<int, int> $eventDateIds */
public function invalidate(
Tenant $tenant,
Collection $eventDateIds,
string $reason = StockReservationService::REASON_EVENT_DATE_RESCHEDULED,
): void {
DB::transaction(function () use ($tenant, $eventDateIds, $reason): void {
$carts = Cart::query()
->where('tenant_codigo', $tenant->codigo)
->where('status', Cart::STATUS_ACTIVE)
->whereNull('current_purchase_id')
->whereHas('currentStockReservation', fn ($reservation) => $reservation
->where('status', StockReservation::STATUS_ACTIVE))
->whereHas('items.variant', fn ($variant) => $variant
->withTrashed()
->where(fn ($dates) => $dates
->whereIn('event_date_id', $eventDateIds)
->orWhereHas('eventDates', fn ($date) => $date
->whereIn('event_dates.id', $eventDateIds))))
->orderBy('id')
->lockForUpdate()
->get();
foreach ($carts as $cart) {
// Checkout keeps the cart and purchase attached to the same reservation.
if (Purchase::query()
->where('stock_reservation_id', $cart->current_stock_reservation_id)
->exists()) {
continue;
}
$this->reservations->releaseCurrentCartReservation(
$cart,
$reason,
);
// Reuse the existing expired-cart flow: stale mutations receive the
// expiration response and the next GET replaces the whole cart.
$cart->update(['status' => Cart::STATUS_EXPIRED]);
}
});
}
}

View File

@@ -59,6 +59,8 @@ class Attribute extends Model
public function eventDates(): HasMany public function eventDates(): HasMany
{ {
return $this->hasMany(EventDate::class, 'tenant_code', 'tenant_codigo') return $this->hasMany(EventDate::class, 'tenant_code', 'tenant_codigo')
->whereNull('rescheduled_to_event_date_id')
->whereNull('suspended_at')
->orderBy('date') ->orderBy('date')
->orderBy('time_start'); ->orderBy('time_start');
} }

View File

@@ -179,12 +179,28 @@ class CatalogItem extends Model
{ {
return $query->where(function (Builder $query): void { return $query->where(function (Builder $query): void {
$query $query
->where(function (Builder $unlimitedQuery): void {
$unlimitedQuery
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value) ->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->where(function (Builder $selectionQuery): void {
$selectionQuery
->whereDoesntHave('variants')
->orWhereHas('variants', fn (Builder $variantQuery): Builder => $variantQuery
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id'));
});
})
->orWhereHas( ->orWhereHas(
'variants.inventory', 'variants',
fn (Builder $variantQuery): Builder => $variantQuery
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->whereHas(
'inventory',
fn (Builder $inventoryQuery): Builder => $inventoryQuery fn (Builder $inventoryQuery): Builder => $inventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock') ->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
) )
)
->orWhere(function (Builder $directItemQuery): void { ->orWhere(function (Builder $directItemQuery): void {
$directItemQuery $directItemQuery
->whereDoesntHave('variants') ->whereDoesntHave('variants')
@@ -205,9 +221,12 @@ class CatalogItem extends Model
public function visibleVariants(?int $includedVariantId = null): Collection public function visibleVariants(?int $includedVariantId = null): Collection
{ {
return $this->variants return $this->variants
->filter(fn (Variant $variant): bool => ($includedVariantId !== null && $variant->id === $includedVariantId) ->filter(fn (Variant $variant): bool => $variant->hasOnlyActiveEventDates()
|| $this->inventory_policy === InventoryPolicy::Unlimited && (($includedVariantId !== null && $variant->id === $includedVariantId)
|| ($variant->inventory?->availableStock() ?? 0) > 0) || ($variant->isSellable() && (
$this->inventory_policy === InventoryPolicy::Unlimited
|| ($variant->inventory?->availableStock() ?? 0) > 0
))))
->values(); ->values();
} }

View File

@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([ #[Fillable([
'sold_units', 'sold_units',
'refunded_units',
'reserved_stock', 'reserved_stock',
'real_stock', 'real_stock',
])] ])]
@@ -23,6 +24,7 @@ class Inventory extends Model
protected $attributes = [ protected $attributes = [
'sold_units' => 0, 'sold_units' => 0,
'refunded_units' => 0,
'reserved_stock' => 0, 'reserved_stock' => 0,
'real_stock' => 0, 'real_stock' => 0,
]; ];
@@ -31,6 +33,7 @@ class Inventory extends Model
{ {
return [ return [
'sold_units' => 'integer', 'sold_units' => 'integer',
'refunded_units' => 'integer',
'reserved_stock' => 'integer', 'reserved_stock' => 'integer',
'real_stock' => 'integer', 'real_stock' => 'integer',
]; ];
@@ -42,10 +45,10 @@ class Inventory extends Model
return $this->hasOne(CatalogItem::class); return $this->hasOne(CatalogItem::class);
} }
/** @return HasOne<Variant, $this> */ /** @return HasMany<Variant, $this> */
public function variant(): HasOne public function variants(): HasMany
{ {
return $this->hasOne(Variant::class); return $this->hasMany(Variant::class);
} }
/** @return HasMany<StockReservationLine, $this> */ /** @return HasMany<StockReservationLine, $this> */

View File

@@ -20,6 +20,8 @@ use Illuminate\Support\Str;
'catalog_item_id', 'catalog_item_id',
'event_date_id', 'event_date_id',
'inventory_id', 'inventory_id',
'replaced_by_variant_id',
'sales_disabled_at',
'descripcion', 'descripcion',
'precio', 'precio',
])] ])]
@@ -37,6 +39,8 @@ class Variant extends Model
'catalog_item_id' => 'integer', 'catalog_item_id' => 'integer',
'event_date_id' => 'integer', 'event_date_id' => 'integer',
'inventory_id' => 'integer', 'inventory_id' => 'integer',
'replaced_by_variant_id' => 'integer',
'sales_disabled_at' => 'datetime',
'precio' => 'decimal:2', 'precio' => 'decimal:2',
]; ];
} }
@@ -76,6 +80,39 @@ class Variant extends Model
return $this->belongsTo(Inventory::class); return $this->belongsTo(Inventory::class);
} }
/** @return BelongsTo<Variant, $this> */
public function replacement(): BelongsTo
{
return $this->belongsTo(self::class, 'replaced_by_variant_id');
}
/** @return HasMany<Variant, $this> */
public function replacedVariants(): HasMany
{
return $this->hasMany(self::class, 'replaced_by_variant_id');
}
public function isSellable(): bool
{
return $this->sales_disabled_at === null
&& $this->replaced_by_variant_id === null
&& $this->hasOnlyActiveEventDates();
}
public function hasOnlyActiveEventDates(): bool
{
if (! $this->exists
&& $this->event_date_id === null
&& ! $this->relationLoaded('eventDates')) {
return true;
}
return $this->selectedEventDates()->every(
fn (EventDate $eventDate): bool => $eventDate->rescheduled_to_event_date_id === null
&& $eventDate->suspended_at === null,
);
}
/** @return HasMany<VariantDefinition, $this> */ /** @return HasMany<VariantDefinition, $this> */
public function definitions(): HasMany public function definitions(): HasMany
{ {

View File

@@ -63,7 +63,12 @@ class CatalogInventoryService
$selection->loadMissing('variants.inventory'); $selection->loadMissing('variants.inventory');
return $selection->variants->sum( return $selection->variants
->filter(fn (Variant $variant): bool => $variant->isSellable())
->unique(fn (Variant $variant): string => $variant->inventory_id === null
? 'object:'.spl_object_id($variant->inventory)
: 'id:'.$variant->inventory_id)
->sum(
fn (Variant $variant): int => $variant->inventory->availableStock(), fn (Variant $variant): int => $variant->inventory->availableStock(),
); );
} }

View File

@@ -207,7 +207,8 @@ class CatalogService
$visibleVariants = $catalogItem->visibleVariants(); $visibleVariants = $catalogItem->visibleVariants();
if ($catalogItem->type === CatalogItemType::Standard if ($catalogItem->type === CatalogItemType::Standard
&& ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty()) && ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty())
&& ! $catalogItem->isAvailable()) { && (($catalogItem->variants->isNotEmpty() && $visibleVariants->isEmpty())
|| ! $catalogItem->isAvailable())) {
throw new NotFoundHttpException('Catalog item is out of stock.'); throw new NotFoundHttpException('Catalog item is out of stock.');
} }
@@ -324,9 +325,13 @@ class CatalogService
->findOrFail($variant->catalog_item_id); ->findOrFail($variant->catalog_item_id);
$variant->delete(); $variant->delete();
if (! $catalogItem->variants()->exists()) { $sellableVariants = $catalogItem->variants()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id');
if (! (clone $sellableVariants)->exists()) {
$this->delete($catalogItem); $this->delete($catalogItem);
} elseif (($minimumPrice = $catalogItem->variants()->min('precio')) !== null) { } elseif (($minimumPrice = (clone $sellableVariants)->min('precio')) !== null) {
$catalogItem->update(['precio' => $minimumPrice]); $catalogItem->update(['precio' => $minimumPrice]);
} }
@@ -399,7 +404,11 @@ class CatalogService
]); ]);
} }
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) { if ($variantId !== null && ! $componentItem->variants()
->whereKey($variantId)
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->exists()) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
"components.{$index}.variant_id" => [ "components.{$index}.variant_id" => [
__('api.catalog.component_variant_invalid'), __('api.catalog.component_variant_invalid'),

View File

@@ -19,6 +19,10 @@ class StockReservationService
public const REASON_CART_CHANGED = 'cart_changed'; public const REASON_CART_CHANGED = 'cart_changed';
public const REASON_EVENT_DATE_RESCHEDULED = 'event_date_rescheduled';
public const REASON_EVENT_DATE_SUSPENDED = 'event_date_suspended';
public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded'; public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded';
public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled'; public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled';

View File

@@ -0,0 +1,263 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Models\BundleComponent;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\StockReservationLine;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\EventDate;
use Illuminate\Support\Collection;
class VariantReplacementService
{
/** @return Collection<int, Variant> */
public function replaceEventDate(EventDate $source, EventDate $destination): Collection
{
$variants = Variant::query()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->where(function ($query) use ($source): void {
$query->where('event_date_id', $source->getKey())
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
->where('event_dates.id', $source->getKey()));
})
->with(['eventDates', 'eventDate', 'definitions', 'allAttachments'])
->orderBy('id')
->lockForUpdate()
->get();
return $variants->map(function (Variant $variant) use ($source, $destination): Variant {
$destinationDateIds = $variant->selectedEventDates()
->pluck('id')
->map(fn ($id): int => (int) $id === (int) $source->getKey()
? (int) $destination->getKey()
: (int) $id)
->unique()
->sort()
->values();
$replacement = $this->findEquivalent($variant, $destinationDateIds)
?? $this->cloneWithDates($variant, $destinationDateIds);
$variant->update([
'replaced_by_variant_id' => $replacement->getKey(),
'sales_disabled_at' => now(),
]);
BundleComponent::query()
->where('component_variant_id', $variant->getKey())
->update(['component_variant_id' => $replacement->getKey()]);
return $replacement;
})->values();
}
public function disableForSuspension(EventDate $eventDate): void
{
$variants = Variant::query()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->where(function ($query) use ($eventDate): void {
$query->where('event_date_id', $eventDate->getKey())
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
->where('event_dates.id', $eventDate->getKey()));
})
->with(['eventDates', 'eventDate', 'definitions', 'allAttachments'])
->orderBy('id')
->lockForUpdate()
->get();
foreach ($variants as $variant) {
$remainingDateIds = $variant->selectedEventDates()
->filter(fn (EventDate $date): bool => $date->suspended_at === null
&& $date->rescheduled_to_event_date_id === null)
->pluck('id')
->map(fn ($id): int => (int) $id)
->unique()
->sort()
->values();
if ($remainingDateIds->isEmpty()) {
$variant->update(['sales_disabled_at' => now()]);
continue;
}
$replacement = $this->findEquivalent($variant, $remainingDateIds);
if ($replacement === null) {
$replacement = $this->cloneWithDates($variant, $remainingDateIds);
} else {
$this->mergeInventoryInto($variant, $replacement);
}
$variant->update([
'replaced_by_variant_id' => $replacement->getKey(),
'sales_disabled_at' => now(),
]);
BundleComponent::query()
->where('component_variant_id', $variant->getKey())
->update(['component_variant_id' => $replacement->getKey()]);
}
}
/** @param Collection<int, int> $eventDateIds */
private function findEquivalent(Variant $source, Collection $eventDateIds): ?Variant
{
$definitionSignature = $this->definitionSignature($source);
$dateSignature = $eventDateIds->map(fn ($id): int => (int) $id)->sort()->values()->all();
return Variant::query()
->where('catalog_item_id', $source->catalog_item_id)
->whereKeyNot($source->getKey())
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->with(['eventDates', 'eventDate', 'definitions'])
->orderBy('id')
->lockForUpdate()
->get()
->first(fn (Variant $candidate): bool => $this->definitionSignature($candidate) === $definitionSignature
&& $candidate->selectedEventDates()
->pluck('id')
->map(fn ($id): int => (int) $id)
->sort()
->values()
->all() === $dateSignature
);
}
/** @param Collection<int, int> $eventDateIds */
private function cloneWithDates(Variant $source, Collection $eventDateIds): Variant
{
$replacementInventory = $this->cloneInventory($source);
$replacement = $source->replicate([
'event_date_id',
'inventory_id',
'replaced_by_variant_id',
'sales_disabled_at',
]);
$replacement->inventory_id = $replacementInventory->getKey();
$replacement->event_date_id = $eventDateIds->count() === 1
? $eventDateIds->first()
: null;
$replacement->save();
$replacement->eventDates()->sync($eventDateIds->all());
$replacement->definitions()->createMany(
$source->definitions
->map(fn ($definition): array => [
'item_attribute_id' => $definition->item_attribute_id,
'value' => $definition->value,
])
->all(),
);
$attachments = $source->allAttachments
->mapWithKeys(fn ($attachment): array => [
$attachment->getKey() => [
'orden' => $attachment->pivot->orden,
'is_enabled' => $attachment->pivot->is_enabled,
],
])
->all();
$replacement->allAttachments()->sync($attachments);
return $replacement->load(['eventDates', 'eventDate', 'definitions', 'allAttachments']);
}
private function cloneInventory(Variant $source): Inventory
{
$activeLines = StockReservationLine::query()
->where('inventory_id', $source->inventory_id)
->whereHas('reservation', fn ($reservation) => $reservation
->where('status', StockReservation::STATUS_ACTIVE))
->orderBy('id')
->lockForUpdate()
->get();
$sourceInventory = Inventory::query()
->whereKey($source->inventory_id)
->lockForUpdate()
->firstOrFail();
$reservedStock = (int) $activeLines->sum('quantity');
if ($sourceInventory->reserved_stock !== $reservedStock) {
throw new \LogicException('El inventario reservado de la variante es inconsistente.');
}
$replacementInventory = Inventory::query()->create([
'sold_units' => $sourceInventory->sold_units,
'refunded_units' => $sourceInventory->refunded_units,
'reserved_stock' => $reservedStock,
'real_stock' => $sourceInventory->real_stock,
]);
if ($activeLines->isNotEmpty()) {
StockReservationLine::query()
->whereKey($activeLines->modelKeys())
->update(['inventory_id' => $replacementInventory->getKey()]);
}
$sourceInventory->update(['reserved_stock' => 0]);
return $replacementInventory;
}
private function mergeInventoryInto(Variant $source, Variant $destination): void
{
if ($source->inventory_id === $destination->inventory_id) {
return;
}
$inventories = Inventory::query()
->whereKey([$source->inventory_id, $destination->inventory_id])
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
$sourceInventory = $inventories->get($source->inventory_id);
$destinationInventory = $inventories->get($destination->inventory_id);
if ($sourceInventory === null || $destinationInventory === null) {
throw new \LogicException('No se encontró el inventario de una variante.');
}
$activeLines = StockReservationLine::query()
->where('inventory_id', $source->inventory_id)
->whereHas('reservation', fn ($reservation) => $reservation
->where('status', StockReservation::STATUS_ACTIVE))
->orderBy('id')
->lockForUpdate()
->get();
if ($sourceInventory->reserved_stock !== (int) $activeLines->sum('quantity')) {
throw new \LogicException('El inventario reservado de la variante es inconsistente.');
}
$destinationInventory->update([
'real_stock' => $destinationInventory->real_stock + $sourceInventory->real_stock,
'reserved_stock' => $destinationInventory->reserved_stock + $sourceInventory->reserved_stock,
'sold_units' => $destinationInventory->sold_units + $sourceInventory->sold_units,
'refunded_units' => $destinationInventory->refunded_units + $sourceInventory->refunded_units,
]);
if ($activeLines->isNotEmpty()) {
StockReservationLine::query()
->whereKey($activeLines->modelKeys())
->update(['inventory_id' => $destinationInventory->getKey()]);
}
$sourceInventory->update([
'real_stock' => 0,
'reserved_stock' => 0,
'sold_units' => 0,
'refunded_units' => 0,
]);
}
/** @return list<string> */
private function definitionSignature(Variant $variant): array
{
return $variant->definitions
->map(fn ($definition): string => $definition->item_attribute_id.'\0'.$definition->value)
->sort()
->values()
->all();
}
}

View File

@@ -105,7 +105,8 @@ class InvitationPurchaseProvisioner
private function userId(DateTimeInterface $now): int private function userId(DateTimeInterface $now): int
{ {
$user = DB::table('users') $user = DB::table('users')
->where('email', self::USER_EMAIL) ->where('active_email', self::USER_EMAIL)
->where('rol_codigo', 'user')
->whereNull('deleted_at') ->whereNull('deleted_at')
->first(); ->first();

View File

@@ -2,7 +2,11 @@
namespace App\Domains\Event\Controllers\AdminApp; namespace App\Domains\Event\Controllers\AdminApp;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Requests\RescheduleEventDateRequest;
use App\Domains\Event\Requests\StoreEventDateRequest;
use App\Domains\Event\Requests\UpdateEventRequest; use App\Domains\Event\Requests\UpdateEventRequest;
use App\Domains\Event\Resources\EventDateResource;
use App\Domains\Event\Resources\EventResource; use App\Domains\Event\Resources\EventResource;
use App\Domains\Event\Services\EventService; use App\Domains\Event\Services\EventService;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
@@ -28,4 +32,39 @@ class EventController extends Controller
) )
); );
} }
public function storeDate(StoreEventDateRequest $request): EventDateResource
{
return EventDateResource::make(
$this->eventService->createDateForTenant(
$request->user()->tenant()->firstOrFail(),
$request->validated(),
)
);
}
public function rescheduleDate(
RescheduleEventDateRequest $request,
EventDate $eventDate,
): EventDateResource {
return EventDateResource::make(
$this->eventService->rescheduleDateForTenant(
$request->user()->tenant()->firstOrFail(),
$eventDate,
$request->validated(),
$request->user(),
)
);
}
public function suspendDate(Request $request, EventDate $eventDate): EventDateResource
{
return EventDateResource::make(
$this->eventService->suspendDateForTenant(
$request->user()->tenant()->firstOrFail(),
$eventDate,
$request->user(),
)
);
}
} }

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Event\Controllers;
use App\Domains\Event\Resources\EventDateNoticeResource;
use App\Domains\Event\Services\EventDateNoticeService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class EventDateNoticeController extends Controller
{
public function __construct(private readonly EventDateNoticeService $noticeService) {}
public function claim(Request $request, Tenant $tenant): AnonymousResourceCollection
{
return EventDateNoticeResource::collection(
$this->noticeService->claimFor($request->user(), $tenant)
);
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Event\Enums;
enum EventDateChangeType: string
{
case Rescheduled = 'rescheduled';
case Suspended = 'suspended';
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Domains\Event\Enums;
enum EventDateStatus: string
{
case Rescheduled = 'rescheduled';
case Suspended = 'suspended';
case Scheduled = 'scheduled';
case InProgress = 'in_progress';
case Completed = 'completed';
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Event\Events;
use Illuminate\Foundation\Events\Dispatchable;
class EventDateRescheduled
{
use Dispatchable;
/**
* @param list<array{purchase_id: int, ticket_ids: list<int>}> $purchaseTickets
*/
public function __construct(
public readonly string $tenantCode,
public readonly int $sourceEventDateId,
public readonly int $destinationEventDateId,
public readonly string $previousDate,
public readonly string $newDate,
public readonly array $purchaseTickets,
) {}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Event\Events;
use Illuminate\Foundation\Events\Dispatchable;
class EventDateSuspended
{
use Dispatchable;
/**
* @param list<array{purchase_id: int, ticket_ids: list<int>}> $purchaseTickets
*/
public function __construct(
public readonly string $tenantCode,
public readonly int $eventDateId,
public readonly string $date,
public readonly array $purchaseTickets,
) {}
}

View File

@@ -3,6 +3,8 @@
namespace App\Domains\Event\Models; namespace App\Domains\Event\Models;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Enums\EventDateStatus;
use App\Domains\Event\Services\EffectiveEventDateResolver;
use App\Domains\Event\Services\EventDateTextFormatter; use App\Domains\Event\Services\EventDateTextFormatter;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Enums\ValidityTimeType;
@@ -21,6 +23,8 @@ use Illuminate\Support\Carbon;
'date', 'date',
'time_start', 'time_start',
'time_end', 'time_end',
'rescheduled_to_event_date_id',
'suspended_at',
])] ])]
class EventDate extends Model class EventDate extends Model
{ {
@@ -28,6 +32,8 @@ class EventDate extends Model
public $timestamps = false; public $timestamps = false;
protected $appends = ['status'];
protected static function booted(): void protected static function booted(): void
{ {
static::creating(fn (self $eventDate) => $eventDate->syncValidityTime()); static::creating(fn (self $eventDate) => $eventDate->syncValidityTime());
@@ -52,6 +58,8 @@ class EventDate extends Model
return [ return [
'date' => 'date:Y-m-d', 'date' => 'date:Y-m-d',
'validity_time_id' => 'integer', 'validity_time_id' => 'integer',
'rescheduled_to_event_date_id' => 'integer',
'suspended_at' => 'datetime',
]; ];
} }
@@ -67,6 +75,35 @@ class EventDate extends Model
return $this->belongsTo(ValidityTime::class); return $this->belongsTo(ValidityTime::class);
} }
/** @return BelongsTo<EventDate, $this> */
public function rescheduledTo(): BelongsTo
{
return $this->belongsTo(self::class, 'rescheduled_to_event_date_id');
}
public function effectiveDate(): ?self
{
return app(EffectiveEventDateResolver::class)->resolve($this);
}
/** @return HasMany<EventDate, $this> */
public function rescheduledFrom(): HasMany
{
return $this->hasMany(self::class, 'rescheduled_to_event_date_id');
}
/** @return HasMany<EventDateChange, $this> */
public function changeHistory(): HasMany
{
return $this->hasMany(EventDateChange::class, 'source_event_date_id');
}
/** @return HasMany<EventDateChange, $this> */
public function destinationChangeHistory(): HasMany
{
return $this->hasMany(EventDateChange::class, 'destination_event_date_id');
}
/** @return HasMany<Variant, $this> */ /** @return HasMany<Variant, $this> */
public function variants(): HasMany public function variants(): HasMany
{ {
@@ -91,7 +128,32 @@ class EventDate extends Model
public function endsAt(): CarbonInterface public function endsAt(): CarbonInterface
{ {
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end); $endsAt = Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end);
return $endsAt->lessThanOrEqualTo($this->startsAt())
? $endsAt->addDay()
: $endsAt;
}
public function getStatusAttribute(): EventDateStatus
{
if ($this->rescheduled_to_event_date_id !== null) {
return EventDateStatus::Rescheduled;
}
if ($this->suspended_at !== null) {
return EventDateStatus::Suspended;
}
if (now()->lt($this->startsAt())) {
return EventDateStatus::Scheduled;
}
if (now()->lt($this->endsAt())) {
return EventDateStatus::InProgress;
}
return EventDateStatus::Completed;
} }
private function syncTenantDateText(): void private function syncTenantDateText(): void
@@ -104,7 +166,10 @@ class EventDate extends Model
$tenant->update([ $tenant->update([
'event_date_text' => app(EventDateTextFormatter::class)->format( 'event_date_text' => app(EventDateTextFormatter::class)->format(
$tenant->eventDates()->pluck('date') $tenant->eventDates()
->whereNull('rescheduled_to_event_date_id')
->whereNull('suspended_at')
->pluck('date')
), ),
]); ]);
} }
@@ -114,10 +179,6 @@ class EventDate extends Model
$startsAt = $this->startsAt(); $startsAt = $this->startsAt();
$expiresAt = $this->endsAt(); $expiresAt = $this->endsAt();
if ($expiresAt->lessThanOrEqualTo($startsAt)) {
$expiresAt = $expiresAt->addDay();
}
$attributes = [ $attributes = [
'type' => ValidityTimeType::FixedWindow, 'type' => ValidityTimeType::FixedWindow,
'start_time' => null, 'start_time' => null,

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Domains\Event\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Event\Enums\EventDateChangeType;
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\HasMany;
#[Fillable([
'tenant_code',
'change_type',
'source_event_date_id',
'destination_event_date_id',
'created_by_user_id',
'previous_date',
'new_date',
])]
class EventDateChange extends Model
{
public $timestamps = false;
protected function casts(): array
{
return [
'change_type' => EventDateChangeType::class,
'source_event_date_id' => 'integer',
'destination_event_date_id' => 'integer',
'created_by_user_id' => 'integer',
'previous_date' => 'date:Y-m-d',
'new_date' => 'date:Y-m-d',
'created_at' => 'datetime',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<EventDate, $this> */
public function sourceEventDate(): BelongsTo
{
return $this->belongsTo(EventDate::class, 'source_event_date_id');
}
/** @return BelongsTo<EventDate, $this> */
public function destinationEventDate(): BelongsTo
{
return $this->belongsTo(EventDate::class, 'destination_event_date_id');
}
/** @return BelongsTo<User, $this> */
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id')->withTrashed();
}
/** @return HasMany<EventDateChangeView, $this> */
public function views(): HasMany
{
return $this->hasMany(EventDateChangeView::class);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Domains\Event\Models;
use App\Domains\Auth\Models\User;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'user_id',
'event_date_change_id',
'display_count',
'last_displayed_at',
])]
class EventDateChangeView extends Model
{
protected $table = 'user_event_date_change_views';
protected function casts(): array
{
return [
'user_id' => 'integer',
'event_date_change_id' => 'integer',
'display_count' => 'integer',
'last_displayed_at' => 'datetime',
];
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** @return BelongsTo<EventDateChange, $this> */
public function eventDateChange(): BelongsTo
{
return $this->belongsTo(EventDateChange::class);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Event\Requests;
use Illuminate\Foundation\Http\FormRequest;
class RescheduleEventDateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'date' => ['required', 'date_format:Y-m-d'],
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Domains\Event\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEventDateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'date' => ['required', 'date_format:Y-m-d'],
'start_time' => ['required', 'date_format:H:i'],
'end_time' => ['required', 'date_format:H:i'],
];
}
}

View File

@@ -19,11 +19,6 @@ class UpdateEventRequest extends FormRequest
return [ return [
'title' => ['required', 'string', 'max:255'], 'title' => ['required', 'string', 'max:255'],
'location' => ['required', 'string', 'max:255'], 'location' => ['required', 'string', 'max:255'],
'dates' => ['required', 'array', 'min:1'],
'dates.*' => ['required', 'array:date,start_time,end_time'],
'dates.*.date' => ['required', 'date_format:Y-m-d', 'distinct'],
'dates.*.start_time' => ['required', 'date_format:H:i'],
'dates.*.end_time' => ['required', 'date_format:H:i'],
'social_media' => ['sometimes', 'array'], 'social_media' => ['sometimes', 'array'],
'social_media.*' => ['required', 'array:code,url,orden'], 'social_media.*' => ['required', 'array:code,url,orden'],
'social_media.*.code' => [ 'social_media.*.code' => [
@@ -38,6 +33,16 @@ class UpdateEventRequest extends FormRequest
'contact.whatsapp_url' => ['nullable', 'url', 'max:2048'], 'contact.whatsapp_url' => ['nullable', 'url', 'max:2048'],
'contact.instagram_url' => ['nullable', 'url', 'max:2048'], 'contact.instagram_url' => ['nullable', 'url', 'max:2048'],
'contact.facebook_url' => ['nullable', 'url', 'max:2048'], 'contact.facebook_url' => ['nullable', 'url', 'max:2048'],
'allow_ticket_refund' => ['sometimes', 'boolean'],
'allow_ticket_total_refund' => ['sometimes', 'boolean'],
'allow_ticket_partial_refund' => ['sometimes', 'boolean'],
'ticket_partial_refund_percentage' => [
'sometimes',
'numeric',
'decimal:0,2',
'min:0',
'max:99.99',
],
]; ];
} }
@@ -54,6 +59,41 @@ class UpdateEventRequest extends FormRequest
'The social media field is required.' 'The social media field is required.'
); );
} }
if (! array_key_exists('allow_ticket_refund', $input)) {
return;
}
foreach ([
'allow_ticket_total_refund',
'allow_ticket_partial_refund',
'ticket_partial_refund_percentage',
] as $field) {
if (! array_key_exists($field, $input)) {
$validator->errors()->add($field, 'El campo es obligatorio.');
}
}
$totalEnabled = $this->boolean('allow_ticket_total_refund');
$partialEnabled = $this->boolean('allow_ticket_partial_refund');
$refundEnabled = $this->boolean('allow_ticket_refund');
if ($refundEnabled && ! $totalEnabled && ! $partialEnabled) {
$validator->errors()->add(
'allow_ticket_refund',
'Seleccioná al menos un tipo de reembolso.'
);
}
if ($refundEnabled
&& $partialEnabled
&& (float) ($input['ticket_partial_refund_percentage'] ?? 0) <= 0) {
$validator->errors()->add(
'ticket_partial_refund_percentage',
'Ingresá un porcentaje mayor que cero para el reembolso parcial.'
);
}
}, },
]; ];
} }

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Event\Resources;
use App\Domains\Event\Models\EventDateChange;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin EventDateChange */
class EventDateChangeResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'type' => $this->change_type->value,
'source_event_date_id' => $this->source_event_date_id,
'destination_event_date_id' => $this->destination_event_date_id,
'previous_date' => $this->previous_date->format('Y-m-d'),
'new_date' => $this->new_date?->format('Y-m-d'),
'occurred_at' => $this->created_at->toISOString(),
];
}
}

View File

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

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Event\Resources;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin EventDate */
class EventDateResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'validity_time_id' => $this->validity_time_id,
'validity_time' => ValidityTimeResource::make($this->whenLoaded('validityTime')),
'date' => $this->date->format('Y-m-d'),
'start_time' => substr($this->time_start, 0, 5),
'end_time' => substr($this->time_end, 0, 5),
'status' => $this->status->value,
'rescheduled_to_event_date_id' => $this->rescheduled_to_event_date_id,
'suspended_at' => $this->suspended_at?->toISOString(),
'rescheduled_dates' => EventDateResource::collection(
$this->whenLoaded('adminRescheduledDates')
),
];
}
}

View File

@@ -2,8 +2,8 @@
namespace App\Domains\Event\Resources; namespace App\Domains\Event\Resources;
use App\Domains\Event\Services\EventDateGroupingService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -19,14 +19,13 @@ class EventResource extends JsonResource
'id' => $this->id, 'id' => $this->id,
'title' => $this->event_title, 'title' => $this->event_title,
'location' => $this->event_location, 'location' => $this->event_location,
'dates' => $this->eventDates->map(fn ($eventDate): array => [ 'allow_ticket_refund' => $this->allow_ticket_refund,
'id' => $eventDate->id, 'allow_ticket_total_refund' => $this->allow_ticket_total_refund,
'validity_time_id' => $eventDate->validity_time_id, 'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund,
'validity_time' => ValidityTimeResource::make($eventDate->validityTime), 'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage,
'date' => $eventDate->date->format('Y-m-d'), 'dates' => EventDateResource::collection(
'start_time' => substr($eventDate->time_start, 0, 5), app(EventDateGroupingService::class)->group($this->eventDates)
'end_time' => substr($eventDate->time_end, 0, 5), ),
])->values(),
'social_media' => $this->socialMedia->map(fn ($item): array => [ 'social_media' => $this->socialMedia->map(fn ($item): array => [
'code' => $item->code, 'code' => $item->code,
'url' => $item->pivot->url, 'url' => $item->pivot->url,

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Services\TicketValidityResolver;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class AffectedEventDatePurchaseResolver
{
/**
* Finds active tickets belonging to paid purchases before an event-date mutation.
*
* @param Collection<int, int>|list<int> $eventDateIds
* @return list<array{purchase_id: int, ticket_ids: list<int>}>
*/
public function resolve(Tenant $tenant, Collection|array $eventDateIds): array
{
$eventDateIds = collect($eventDateIds)->map(fn (mixed $id): int => (int) $id)->unique()->values();
if ($eventDateIds->isEmpty()) {
return [];
}
/** @var Collection<int, Ticket> $tickets */
$tickets = Ticket::query()
->where('tenant_code', $tenant->codigo)
->whereHas('sourcePurchaseItem.purchase', fn (Builder $query) => $query
->where('status', Purchase::STATUS_PAID))
->whereHas('sourceVariant', function (Builder $query) use ($eventDateIds): void {
$query->whereIn('event_date_id', $eventDateIds)
->orWhereHas('eventDates', fn (Builder $eventDates) => $eventDates
->whereIn('event_dates.id', $eventDateIds));
})
->with([
...TicketValidityResolver::RELATIONS,
'sourcePurchaseItem.purchase',
])
->get()
->filter(fn (Ticket $ticket): bool => $ticket->is_active())
->values();
return $tickets
->groupBy(fn (Ticket $ticket): int => (int) $ticket->sourcePurchaseItem->purchase->getKey())
->map(function (Collection $purchaseTickets): array {
return [
'purchase_id' => (int) $purchaseTickets->first()->sourcePurchaseItem->purchase->getKey(),
'ticket_ids' => $purchaseTickets->modelKeys(),
];
})
->values()
->all();
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Event\Models\EventDate;
class EffectiveEventDateResolver
{
public function resolve(EventDate $eventDate): ?EventDate
{
$date = $this->resolveLatest($eventDate);
return $date !== null && $date->suspended_at === null ? $date : null;
}
/** Sigue las reprogramaciones para presentación, incluso si el destino está suspendido. */
public function resolveLatest(EventDate $eventDate): ?EventDate
{
$current = $eventDate;
$visited = [];
while (true) {
$identity = $current->getKey() === null
? 'object:'.spl_object_id($current)
: 'key:'.$current->getKey();
if (isset($visited[$identity])) {
return null;
}
$visited[$identity] = true;
if ($current->rescheduled_to_event_date_id === null) {
return $current;
}
$current->loadMissing('rescheduledTo');
$current = $current->rescheduledTo;
if ($current === null) {
return null;
}
}
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Event\Models\EventDate;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
class EventDateGroupingService
{
/**
* Groups every historical date under its final active destination.
*
* @param Collection<int, EventDate> $dates
* @return Collection<int, EventDate>
*/
public function group(Collection $dates): Collection
{
$byId = $dates->keyBy(fn (EventDate $date): int => (int) $date->getKey());
$groups = collect();
foreach ($dates as $date) {
$destination = $this->finalDestination($date, $byId);
$key = (int) $destination->getKey();
if (! $groups->has($key)) {
$groups->put($key, [
'destination' => $destination,
'rescheduled' => collect(),
]);
}
if (! $date->is($destination)) {
$group = $groups->get($key);
$historicalDate = clone $date;
$historicalDate->setAttribute(
'rescheduled_to_event_date_id',
$destination->getKey(),
);
$group['rescheduled']->push($historicalDate);
$groups->put($key, $group);
}
}
return $groups
->map(function (array $group): EventDate {
/** @var EventDate $destination */
$destination = clone $group['destination'];
/** @var Collection<int, EventDate> $rescheduled */
$rescheduled = $group['rescheduled'];
$destination->setRelation(
'adminRescheduledDates',
new EloquentCollection($rescheduled->sort($this->dateSorter())->values()->all()),
);
return $destination;
})
->sort($this->dateSorter())
->values();
}
/** @param Collection<int, EventDate> $byId */
private function finalDestination(EventDate $date, Collection $byId): EventDate
{
$current = $date;
$visited = collect();
while ($current->rescheduled_to_event_date_id !== null) {
$currentId = (int) $current->getKey();
if ($visited->contains($currentId)) {
break;
}
$visited->push($currentId);
$destination = $byId->get((int) $current->rescheduled_to_event_date_id);
if (! $destination instanceof EventDate) {
break;
}
$current = $destination;
}
return $current;
}
/** @return callable(EventDate, EventDate): int */
private function dateSorter(): callable
{
return fn (EventDate $left, EventDate $right): int => [
$left->date->format('Y-m-d'),
$left->time_start,
$left->getKey(),
] <=> [
$right->date->format('Y-m-d'),
$right->time_start,
$right->getKey(),
];
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Event\Enums\EventDateChangeType;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Models\EventDateChange;
use Illuminate\Support\Collection;
class EventDateInfoFormatter
{
/** @param Collection<int, EventDateChange> $changes */
public function format(EventDate $eventDate, Collection $changes): ?string
{
$messages = collect();
if ($eventDate->suspended_at !== null) {
$messages->push('Esta fecha fue cancelada.');
}
$sourceDates = $this->reschedulesEndingAt($eventDate, $changes)
->pluck('previous_date')
->filter()
->map(fn ($date): string => $date->format('d/m/Y'))
->unique()
->values();
if ($sourceDates->isNotEmpty()) {
$verb = $sourceDates->count() === 1 ? 'se reprogramó' : 'se reprogramaron';
$messages->push("{$sourceDates->join(', ', ' y ')} {$verb} para este día.");
}
return $messages->isEmpty() ? null : $messages->join(' ');
}
/**
* Includes direct and intermediate reschedules that ultimately end at the
* displayed event date, while preserving the original change order.
*
* @param Collection<int, EventDateChange> $changes
* @return Collection<int, EventDateChange>
*/
private function reschedulesEndingAt(EventDate $eventDate, Collection $changes): Collection
{
$eventDateId = $eventDate->getKey();
$reschedules = $changes->where('change_type', EventDateChangeType::Rescheduled);
if ($eventDateId === null) {
return $reschedules->where('destination_event_date_id', null);
}
$destinationIds = [(int) $eventDateId => true];
do {
$foundAncestor = false;
foreach ($reschedules as $change) {
$destinationId = $change->destination_event_date_id;
$sourceId = $change->source_event_date_id;
if ($destinationId === null || $sourceId === null) {
continue;
}
if (isset($destinationIds[(int) $destinationId]) && ! isset($destinationIds[(int) $sourceId])) {
$destinationIds[(int) $sourceId] = true;
$foundAncestor = true;
}
}
} while ($foundAncestor);
return $reschedules
->filter(fn (EventDateChange $change): bool => $change->destination_event_date_id !== null
&& isset($destinationIds[(int) $change->destination_event_date_id]));
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Event\Enums\EventDateChangeType;
use App\Domains\Event\Models\EventDateChange;
use Illuminate\Support\Collection;
class EventDateNoticeFormatter
{
public function __construct(private readonly EventDateTextFormatter $dateTextFormatter) {}
/**
* @param Collection<int, EventDateChange> $changes
* @return list<array{
* type: string,
* change_ids: list<int>,
* title: string,
* message: list<array{text: string, bold: bool}>
* }>
*/
public function format(Collection $changes): array
{
return collect([
$this->suspensionNotice(
$changes->where('change_type', EventDateChangeType::Suspended)
),
$this->rescheduleNotice(
$changes->where('change_type', EventDateChangeType::Rescheduled)
),
])->filter()->values()->all();
}
/**
* @param Collection<int, EventDateChange> $changes
* @return array{type: string, change_ids: list<int>, title: string, message: list<array{text: string, bold: bool}>}|null
*/
private function suspensionNotice(Collection $changes): ?array
{
$dates = $this->formatDates($changes, 'previous_date');
if ($dates === null) {
return null;
}
$plural = $changes->count() > 1;
return [
'type' => EventDateChangeType::Suspended->value,
'change_ids' => $this->changeIds($changes),
'title' => $plural ? 'FECHAS CANCELADAS!' : 'FECHA CANCELADA!',
'message' => [
['text' => $plural ? 'Las fechas del ' : 'La fecha del ', 'bold' => false],
['text' => $dates, 'bold' => true],
['text' => $plural ? ' han sido canceladas.' : ' ha sido cancelada.', 'bold' => false],
],
];
}
/**
* @param Collection<int, EventDateChange> $changes
* @return array{type: string, change_ids: list<int>, title: string, message: list<array{text: string, bold: bool}>}|null
*/
private function rescheduleNotice(Collection $changes): ?array
{
$changes = $changes->whereNotNull('new_date');
$sourceDates = $this->formatDates($changes, 'previous_date');
$destinationDates = $this->formatDates($changes, 'new_date');
if ($sourceDates === null || $destinationDates === null) {
return null;
}
$plural = $changes->count() > 1;
$message = [
['text' => $plural ? 'Las fechas del ' : 'La fecha del ', 'bold' => false],
['text' => $sourceDates, 'bold' => true],
[
'text' => $plural ? ' han sido reprogramadas para el ' : ' ha sido reprogramada para el ',
'bold' => false,
],
['text' => $destinationDates, 'bold' => true],
];
if ($plural) {
$message[] = ['text' => ', ', 'bold' => false];
$message[] = ['text' => 'respectivamente', 'bold' => true];
}
$message[] = ['text' => '.', 'bold' => false];
return [
'type' => EventDateChangeType::Rescheduled->value,
'change_ids' => $this->changeIds($changes),
'title' => $plural ? 'FECHAS REPROGRAMADAS!' : 'FECHA REPROGRAMADA!',
'message' => $message,
];
}
/**
* @param Collection<int, EventDateChange> $changes
*/
private function formatDates(Collection $changes, string $attribute): ?string
{
return $this->dateTextFormatter->formatForSentence(
$changes
->pluck($attribute)
->filter()
->map(fn ($date): string => $date->format('Y-m-d'))
);
}
/**
* @param Collection<int, EventDateChange> $changes
* @return list<int>
*/
private function changeIds(Collection $changes): array
{
return $changes
->pluck('id')
->filter(fn ($id): bool => $id !== null)
->map(fn ($id): int => (int) $id)
->values()
->all();
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Event\Models\EventDateChange;
use App\Domains\Event\Models\EventDateChangeView;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
class EventDateNoticeService
{
public const MAX_DISPLAYS = 3;
public function __construct(private readonly EventDateNoticeFormatter $formatter) {}
/**
* Claims one display of every pending change and returns them grouped by type.
*
* @return list<array{
* type: string,
* change_ids: list<int>,
* title: string,
* message: list<array{text: string, bold: bool}>
* }>
*/
public function claimFor(User $user, Tenant $tenant): array
{
return DB::transaction(function () use ($user, $tenant): array {
$lockedUser = User::query()->whereKey($user->getKey())->lockForUpdate()->firstOrFail();
$changes = EventDateChange::query()
->where('tenant_code', $tenant->codigo)
->whereDoesntHave('views', fn ($query) => $query
->where('user_id', $lockedUser->getKey())
->where('display_count', '>=', self::MAX_DISPLAYS))
->orderBy('created_at')
->orderBy('id')
->get();
$notices = $this->formatter->format($changes);
$claimedChangeIds = collect($notices)->pluck('change_ids')->flatten()->unique();
foreach ($claimedChangeIds as $changeId) {
$view = EventDateChangeView::query()->firstOrNew([
'user_id' => $lockedUser->getKey(),
'event_date_change_id' => $changeId,
]);
$view->display_count = min(
self::MAX_DISPLAYS,
((int) $view->display_count) + 1,
);
$view->last_displayed_at = now();
$view->save();
}
return $notices;
});
}
}

View File

@@ -25,6 +25,21 @@ class EventDateTextFormatter
/** @param iterable<string> $dates */ /** @param iterable<string> $dates */
public function format(iterable $dates): ?string public function format(iterable $dates): ?string
{ {
return $this->formatWithOptions($dates, false, false);
}
/** @param iterable<string> $dates */
public function formatForSentence(iterable $dates): ?string
{
return $this->formatWithOptions($dates, true, true);
}
/** @param iterable<string> $dates */
private function formatWithOptions(
iterable $dates,
bool $padDays,
bool $includeYearPreposition,
): ?string {
$normalizedDates = collect($dates) $normalizedDates = collect($dates)
->map(fn (string $date): DateTimeImmutable => new DateTimeImmutable($date)) ->map(fn (string $date): DateTimeImmutable => new DateTimeImmutable($date))
->unique(fn (DateTimeImmutable $date): string => $date->format('Y-m-d')) ->unique(fn (DateTimeImmutable $date): string => $date->format('Y-m-d'))
@@ -37,12 +52,14 @@ class EventDateTextFormatter
$years = $normalizedDates $years = $normalizedDates
->groupBy(fn (DateTimeImmutable $date): string => $date->format('Y')) ->groupBy(fn (DateTimeImmutable $date): string => $date->format('Y'))
->map(function ($yearDates, string $year): string { ->map(function ($yearDates, string $year) use ($padDays, $includeYearPreposition): string {
$months = $yearDates $months = $yearDates
->groupBy(fn (DateTimeImmutable $date): string => $date->format('n')) ->groupBy(fn (DateTimeImmutable $date): string => $date->format('n'))
->map(function ($monthDates, string $month): string { ->map(function ($monthDates, string $month) use ($padDays): string {
$days = $monthDates $days = $monthDates
->map(fn (DateTimeImmutable $date): string => (string) ((int) $date->format('j'))) ->map(fn (DateTimeImmutable $date): string => $padDays
? $date->format('d')
: (string) ((int) $date->format('j')))
->values() ->values()
->all(); ->all();
@@ -51,7 +68,7 @@ class EventDateTextFormatter
->values() ->values()
->all(); ->all();
return $this->join($months).' '.$year; return $this->join($months).($includeYearPreposition ? ' de ' : ' ').$year;
}) })
->values() ->values()
->all(); ->all();

View File

@@ -2,7 +2,19 @@
namespace App\Domains\Event\Services; namespace App\Domains\Event\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Services\InvalidateEventDateCartsService;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Catalog\Services\VariantReplacementService;
use App\Domains\Event\Enums\EventDateChangeType;
use App\Domains\Event\Events\EventDateRescheduled;
use App\Domains\Event\Events\EventDateSuspended;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Models\EventDateChange;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -14,6 +26,13 @@ class EventService
'facebook_url' => 'facebook', 'facebook_url' => 'facebook',
]; ];
public function __construct(
private readonly EffectiveEventDateResolver $effectiveEventDateResolver,
private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver,
private readonly VariantReplacementService $variantReplacementService,
private readonly InvalidateEventDateCartsService $invalidateEventDateCarts,
) {}
public function forTenant(Tenant $tenant): Tenant public function forTenant(Tenant $tenant): Tenant
{ {
return $tenant->load(['eventDates.validityTime', 'socialMedia']); return $tenant->load(['eventDates.validityTime', 'socialMedia']);
@@ -27,9 +46,14 @@ class EventService
$tenant->update([ $tenant->update([
'event_title' => $data['title'], 'event_title' => $data['title'],
'event_location' => $data['location'], 'event_location' => $data['location'],
...array_intersect_key($data, array_flip([
'allow_ticket_refund',
'allow_ticket_total_refund',
'allow_ticket_partial_refund',
'ticket_partial_refund_percentage',
])),
]); ]);
$this->syncDates($tenant, $data['dates']);
if (array_key_exists('social_media', $data)) { if (array_key_exists('social_media', $data)) {
$this->syncSocialMedia($tenant, $data['social_media']); $this->syncSocialMedia($tenant, $data['social_media']);
} else { } else {
@@ -40,41 +64,252 @@ class EventService
}); });
} }
/** @param array<int, array{date: string, start_time: string, end_time: string}> $dates */ /** @param array{date: string, start_time: string, end_time: string} $data */
private function syncDates(Tenant $tenant, array $dates): void public function createDateForTenant(Tenant $tenant, array $data): EventDate
{ {
$existingDates = $tenant->eventDates()->get()->values(); return DB::transaction(function () use ($tenant, $data): EventDate {
$attributes = $this->dateAttributes($data);
foreach (array_values($dates) as $index => $date) { if ($tenant->eventDates()->where($attributes)->exists()) {
$attributes = [
'date' => $date['date'],
'time_start' => $date['start_time'],
'time_end' => $date['end_time'],
];
$existingDate = $existingDates->get($index);
if ($existingDate) {
$existingDate->update($attributes);
} else {
$tenant->eventDates()->create($attributes);
}
}
$datesToDelete = $existingDates->slice(count($dates));
if ($datesToDelete->contains(fn ($eventDate): bool => $eventDate
->selectedByVariants()
->whereHas('sourceTickets')
->exists()
|| $eventDate->variants()->whereHas('sourceTickets')->exists())) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'dates' => ['No se puede eliminar una fecha utilizada por tickets generados.'], 'date' => ['La fecha y el horario ya existen.'],
]); ]);
} }
$datesToDelete->each->delete(); return $tenant->eventDates()->create($attributes)->load('validityTime');
$tenant->unsetRelation('eventDates'); });
}
/** @param array{date: string} $data */
public function rescheduleDateForTenant(
Tenant $tenant,
EventDate $eventDate,
array $data,
?User $createdBy = null,
): EventDate {
return DB::transaction(function () use ($tenant, $eventDate, $data, $createdBy): EventDate {
$source = $this->lockedDateForTenant($tenant, $eventDate);
if ($source->suspended_at !== null) {
throw ValidationException::withMessages([
'event_date' => ['No se puede reprogramar una fecha suspendida.'],
]);
}
if ($source->rescheduled_to_event_date_id !== null) {
throw ValidationException::withMessages([
'event_date' => ['La fecha ya fue reprogramada.'],
]);
}
$destination = $tenant->eventDates()
->whereDate('date', $data['date'])
->lockForUpdate()
->first();
if ($destination === null) {
$destination = $tenant->eventDates()->create([
'date' => $data['date'],
'time_start' => $source->time_start,
'time_end' => $source->time_end,
]);
}
if ($destination->is($source) || $this->chainContains($destination, $source)) {
throw ValidationException::withMessages([
'date' => ['La reprogramación generaría una referencia circular.'],
]);
}
$effectiveDestination = $this->effectiveEventDateResolver->resolve($destination);
if ($effectiveDestination === null) {
throw ValidationException::withMessages([
'date' => ['La fecha de destino no es utilizable.'],
]);
}
$affectedDateIds = $this->affectedDateIds($tenant, $source);
$this->invalidateEventDateCarts->invalidate($tenant, $affectedDateIds);
$purchaseTickets = $this->affectedPurchaseResolver->resolve(
$tenant,
$affectedDateIds,
);
$source->update(['rescheduled_to_event_date_id' => $destination->getKey()]);
$this->variantReplacementService->replaceEventDate($source, $effectiveDestination);
EventDateChange::query()->create([
'tenant_code' => $tenant->codigo,
'change_type' => EventDateChangeType::Rescheduled,
'source_event_date_id' => $source->getKey(),
'destination_event_date_id' => $destination->getKey(),
'created_by_user_id' => $createdBy?->getKey(),
'previous_date' => $source->date->format('Y-m-d'),
'new_date' => $destination->date->format('Y-m-d'),
]);
EventDateRescheduled::dispatch(
$tenant->codigo,
$source->getKey(),
$destination->getKey(),
$source->date->format('d/m/Y'),
$destination->date->format('d/m/Y'),
$purchaseTickets,
);
return $source->fresh(['validityTime', 'rescheduledTo.validityTime']);
});
}
public function suspendDateForTenant(
Tenant $tenant,
EventDate $eventDate,
?User $createdBy = null,
): EventDate {
return DB::transaction(function () use ($tenant, $eventDate, $createdBy): EventDate {
$date = $this->lockedDateForTenant($tenant, $eventDate);
if ($date->rescheduled_to_event_date_id !== null) {
throw ValidationException::withMessages([
'event_date' => ['No se puede suspender una fecha que ya fue reprogramada.'],
]);
}
if ($date->suspended_at !== null) {
return $date->load('validityTime');
}
$affectedDateIds = $this->affectedDateIds($tenant, $date);
$this->invalidateEventDateCarts->invalidate(
$tenant,
$affectedDateIds,
StockReservationService::REASON_EVENT_DATE_SUSPENDED,
);
$purchaseTickets = $this->affectedPurchaseResolver->resolve($tenant, $affectedDateIds);
$date->update(['suspended_at' => now()]);
$this->variantReplacementService->disableForSuspension($date);
$this->disableTicketsWithoutUsableDates($tenant, $date);
EventDateChange::query()->create([
'tenant_code' => $tenant->codigo,
'change_type' => EventDateChangeType::Suspended,
'source_event_date_id' => $date->getKey(),
'destination_event_date_id' => null,
'created_by_user_id' => $createdBy?->getKey(),
'previous_date' => $date->date->format('Y-m-d'),
'new_date' => null,
]);
EventDateSuspended::dispatch(
$tenant->codigo,
$date->getKey(),
$date->date->format('d/m/Y'),
$purchaseTickets,
);
return $date->fresh('validityTime');
});
}
private function lockedDateForTenant(Tenant $tenant, EventDate $eventDate): EventDate
{
return $tenant->eventDates()
->whereKey($eventDate->getKey())
->lockForUpdate()
->firstOrFail();
}
private function chainContains(EventDate $start, EventDate $expected): bool
{
$current = $start;
$visited = [];
while ($current->rescheduled_to_event_date_id !== null) {
if ($current->is($expected)) {
return true;
}
if (isset($visited[$current->getKey()])) {
return true;
}
$visited[$current->getKey()] = true;
$current = $current->rescheduledTo()->lockForUpdate()->first();
if ($current === null) {
return false;
}
}
return $current->is($expected);
}
/** @return Collection<int, int> */
private function affectedDateIds(Tenant $tenant, EventDate $eventDate): Collection
{
$affectedDateIds = collect([$eventDate->getKey()]);
$frontier = $affectedDateIds;
while ($frontier->isNotEmpty()) {
$predecessors = $tenant->eventDates()
->whereIn('rescheduled_to_event_date_id', $frontier)
->pluck('id')
->diff($affectedDateIds)
->values();
$affectedDateIds = $affectedDateIds->merge($predecessors)->unique()->values();
$frontier = $predecessors;
}
return $affectedDateIds;
}
private function disableTicketsWithoutUsableDates(Tenant $tenant, EventDate $suspendedDate): void
{
$affectedDateIds = $this->affectedDateIds($tenant, $suspendedDate);
$variants = Variant::withTrashed()
->where(function ($query) use ($affectedDateIds): void {
$query->whereIn('event_date_id', $affectedDateIds)
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
->whereIn('event_dates.id', $affectedDateIds));
})
->with(['eventDates', 'eventDate'])
->get();
foreach ($variants as $variant) {
$hasUsableDate = $variant->selectedEventDates()->contains(
fn (EventDate $candidate): bool => $this->effectiveEventDateResolver->resolve($candidate) !== null
);
if ($hasUsableDate) {
continue;
}
Ticket::query()
->where('tenant_code', $tenant->codigo)
->where('source_variant_id', $variant->getKey())
->whereNull('disabled_at')
->whereNull('cancelled_at')
->whereNull('refunded_at')
->lockForUpdate()
->get()
->each(function (Ticket $ticket): void {
$ticket->markAsDisabled();
$ticket->save();
});
}
}
/**
* @param array{date: string, start_time: string, end_time: string} $data
* @return array{date: string, time_start: string, time_end: string}
*/
private function dateAttributes(array $data): array
{
return [
'date' => $data['date'],
'time_start' => $data['start_time'].':00',
'time_end' => $data['end_time'].':00',
];
} }
/** @param array<string, string|null> $contact */ /** @param array<string, string|null> $contact */

View File

@@ -8,6 +8,7 @@ Administra la configuración temporal de un tenant orientado a eventos y sus fec
- `Models/EventDate.php`: fecha del evento con inicio, fin, tenant y variantes asociadas. - `Models/EventDate.php`: fecha del evento con inicio, fin, tenant y variantes asociadas.
- `Services/EventService.php`: obtiene y actualiza la configuración de evento del tenant. - `Services/EventService.php`: obtiene y actualiza la configuración de evento del tenant.
- `Services/EventDateNoticeService.php`: reclama y agrupa los cambios pendientes de cada usuario.
- `Controllers/AdminApp/EventController.php`: consulta y modificación desde AdminApp. - `Controllers/AdminApp/EventController.php`: consulta y modificación desde AdminApp.
- `UpdateEventRequest`: valida datos y reglas cruzadas de fechas. - `UpdateEventRequest`: valida datos y reglas cruzadas de fechas.
- `EventResource`: serializa la configuración de salida. - `EventResource`: serializa la configuración de salida.
@@ -19,10 +20,18 @@ Bajo `/v1/adminapp/tenant/event`, protegidos por `auth:sanctum` y `adminapp.tena
- `GET`: obtiene la configuración. - `GET`: obtiene la configuración.
- `PUT`: actualiza la configuración. - `PUT`: actualiza la configuración.
Para el storefront autenticado:
- `POST /tenants/{tenant}/event-date-notices/claim`: devuelve hasta un aviso de suspensiones y otro de
reprogramaciones. Cada cambio se muestra como máximo tres veces por usuario.
## Dependencias ## Dependencias
Depende de `Tenant`. Las fechas se vinculan con variantes de `Catalog`, que a su vez pueden generar tickets. Depende de `Tenant`. Las fechas se vinculan con variantes de `Catalog`, que a su vez pueden generar tickets.
## Consideraciones ## Consideraciones
El archivo `routes/api.php` no publica operaciones adicionales. Al modificar fechas debe mantenerse la validación de orden y coherencia temporal de `UpdateEventRequest`. Los avisos se construyen dinámicamente después de excluir los cambios que el usuario ya vio
tres veces. Al reclamar los avisos se incrementa una vez cada cambio incluido, aunque varios
cambios aparezcan agrupados en el mismo mensaje. El reclamo bloquea al usuario durante la
transacción para impedir que pestañas concurrentes superen el máximo.

View File

@@ -8,4 +8,7 @@ Route::prefix('v1/adminapp/tenant')
->group(function (): void { ->group(function (): void {
Route::get('event', [EventController::class, 'show']); Route::get('event', [EventController::class, 'show']);
Route::put('event', [EventController::class, 'update']); Route::put('event', [EventController::class, 'update']);
Route::post('event-dates', [EventController::class, 'storeDate']);
Route::post('event-dates/{eventDate}/reschedule', [EventController::class, 'rescheduleDate']);
Route::post('event-dates/{eventDate}/suspend', [EventController::class, 'suspendDate']);
}); });

View File

@@ -1,3 +1,11 @@
<?php <?php
use App\Domains\Event\Controllers\EventDateNoticeController;
use Illuminate\Support\Facades\Route;
require __DIR__.'/adminapp.php'; require __DIR__.'/adminapp.php';
Route::middleware('auth:sanctum')->post(
'tenants/{tenant:codigo}/event-date-notices/claim',
[EventDateNoticeController::class, 'claim'],
);

View File

@@ -2,6 +2,7 @@
namespace App\Domains\FiestaFutbolInfantil\Controllers; namespace App\Domains\FiestaFutbolInfantil\Controllers;
use App\Domains\FiestaFutbolInfantil\Requests\UpdateHistoricalFoodStockRequest;
use App\Domains\FiestaFutbolInfantil\Requests\UpsertFoodVariantsRequest; use App\Domains\FiestaFutbolInfantil\Requests\UpsertFoodVariantsRequest;
use App\Domains\FiestaFutbolInfantil\Resources\FoodResource; use App\Domains\FiestaFutbolInfantil\Resources\FoodResource;
use App\Domains\FiestaFutbolInfantil\Services\FoodService; use App\Domains\FiestaFutbolInfantil\Services\FoodService;
@@ -32,6 +33,16 @@ class FoodController extends Controller
); );
} }
public function updateHistoricalStock(UpdateHistoricalFoodStockRequest $request): FoodResource
{
return FoodResource::make(
$this->foodService->updateHistoricalStock(
$request->user()->tenant()->firstOrFail(),
$request->validated('variants'),
)
);
}
public function destroy(Request $request, int $food): Response public function destroy(Request $request, int $food): Response
{ {
$this->foodService->delete( $this->foodService->delete(

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateHistoricalFoodStockRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'variants' => ['required', 'array', 'min:1', 'max:500'],
'variants.*' => ['required', 'array:id,stock'],
'variants.*.id' => ['required', 'integer', 'distinct'],
'variants.*.stock' => ['required', 'integer', 'min:0'],
];
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Domains\FiestaFutbolInfantil\Resources;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Validation\ValidationException;
/** @mixin CatalogItem */ /** @mixin CatalogItem */
class EntryResource extends JsonResource class EntryResource extends JsonResource
@@ -12,7 +13,20 @@ class EntryResource extends JsonResource
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
$variant = $this->variants->sole(); $variants = $this->variants->whereNull('replaced_by_variant_id');
if ($variants->count() !== 1) {
throw ValidationException::withMessages([
'entries' => [sprintf(
'La entrada %s tiene %d variantes sin reemplazar (IDs: %s). Se esperaba una.',
$this->id,
$variants->count(),
$variants->pluck('id')->implode(', '),
)],
]);
}
$variant = $variants->first();
return [ return [
'id' => $this->id, 'id' => $this->id,

View File

@@ -3,8 +3,14 @@
namespace App\Domains\FiestaFutbolInfantil\Resources; namespace App\Domains\FiestaFutbolInfantil\Resources;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Enums\EventDateChangeType;
use App\Domains\Event\Enums\EventDateStatus;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Models\EventDateChange;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
/** @mixin CatalogItem */ /** @mixin CatalogItem */
class FoodResource extends JsonResource class FoodResource extends JsonResource
@@ -17,13 +23,54 @@ class FoodResource extends JsonResource
'id' => null, 'id' => null,
'name' => 'Comida', 'name' => 'Comida',
'variants' => [], 'variants' => [],
'history' => [],
]; ];
} }
$currentVariants = $this->variants
->filter(fn (Variant $variant): bool => $this->isCurrent($variant));
$historicalVariants = $this->variants
->filter(fn (Variant $variant): bool => $this->isHistorical($variant));
return [ return [
'id' => $this->id, 'id' => $this->id,
'name' => $this->nombre, 'name' => $this->nombre,
'variants' => $this->variants->map(function ($variant): array { 'variants' => $currentVariants->map($this->variantData(...))->values(),
'history' => $this->historyData($historicalVariants),
];
}
private function isCurrent(Variant $variant): bool
{
if ($variant->sales_disabled_at !== null || $variant->replaced_by_variant_id !== null) {
return false;
}
$status = $variant->selectedEventDates()->first()?->status;
return $status === null || in_array(
$status,
[EventDateStatus::Scheduled, EventDateStatus::InProgress],
true,
);
}
private function isHistorical(Variant $variant): bool
{
return in_array(
$variant->selectedEventDates()->first()?->status,
[
EventDateStatus::Rescheduled,
EventDateStatus::Suspended,
EventDateStatus::Completed,
],
true,
);
}
/** @return array<string, mixed> */
private function variantData(Variant $variant): array
{
$values = $variant->selectionValues(); $values = $variant->selectionValues();
$eventDate = $variant->selectedEventDates()->first(); $eventDate = $variant->selectedEventDates()->first();
@@ -37,7 +84,73 @@ class FoodResource extends JsonResource
'stock' => $variant->inventory->real_stock, 'stock' => $variant->inventory->real_stock,
'price' => number_format($variant->getPrice(), 2, '.', ''), 'price' => number_format($variant->getPrice(), 2, '.', ''),
]; ];
})->values(), }
/**
* @param Collection<int, Variant> $variants
* @return Collection<int, array<string, mixed>>
*/
private function historyData(Collection $variants): Collection
{
return $variants
->filter(fn (Variant $variant): bool => $variant->selectedEventDates()->first() !== null)
->groupBy(fn (Variant $variant): int => (int) $variant->selectedEventDates()->first()->id)
->map(function (Collection $dateVariants): array {
/** @var EventDate $eventDate */
$eventDate = $dateVariants->first()->selectedEventDates()->first();
$status = $this->historicalStatus($eventDate);
$change = $this->changeForStatus($eventDate, $status);
return [
'id' => $eventDate->id,
'change_id' => $change?->id,
'status' => $status->value,
'status_text' => match ($status) {
EventDateStatus::Rescheduled => 'REPROGRAMADA',
EventDateStatus::Suspended => 'CANCELADA',
EventDateStatus::Completed => 'FINALIZADA',
default => '',
},
'event_date_id' => $eventDate->id,
'event_date' => $eventDate->date->format('Y-m-d'),
'replacement_event_date_id' => $status === EventDateStatus::Rescheduled
? ($change?->destination_event_date_id
?? $eventDate->rescheduled_to_event_date_id)
: null,
'replacement_event_date' => $status === EventDateStatus::Rescheduled
? ($change?->new_date?->format('Y-m-d')
?? $eventDate->rescheduledTo?->date?->format('Y-m-d'))
: null,
'occurred_at' => ($change?->created_at ?? $eventDate->endsAt())->toISOString(),
'variants' => $dateVariants->map($this->variantData(...))->values(),
]; ];
})
->sortByDesc('occurred_at')
->values();
}
private function historicalStatus(EventDate $eventDate): EventDateStatus
{
return match ($eventDate->status) {
EventDateStatus::Rescheduled => EventDateStatus::Rescheduled,
EventDateStatus::Suspended => EventDateStatus::Suspended,
default => EventDateStatus::Completed,
};
}
private function changeForStatus(
EventDate $eventDate,
EventDateStatus $status,
): ?EventDateChange {
$changeType = match ($status) {
EventDateStatus::Rescheduled => EventDateChangeType::Rescheduled,
EventDateStatus::Suspended => EventDateChangeType::Suspended,
default => null,
};
return $changeType === null
? null
: $eventDate->changeHistory
->first(fn (EventDateChange $change): bool => $change->change_type === $changeType);
} }
} }

View File

@@ -24,7 +24,13 @@ class EntryService
return CatalogItem::query() return CatalogItem::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas')) ->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
->whereHas('variants', fn ($query) => $query
->whereNull('replaced_by_variant_id')
->whereNull('sales_disabled_at'))
->with([ ->with([
'variants' => fn ($query) => $query
->whereNull('replaced_by_variant_id')
->whereNull('sales_disabled_at'),
'variants.inventory', 'variants.inventory',
'variants.eventDate', 'variants.eventDate',
'variants.eventDates', 'variants.eventDates',
@@ -97,6 +103,7 @@ class EntryService
$variants = Variant::query() $variants = Variant::query()
->where('catalog_item_id', $catalogItem->id) ->where('catalog_item_id', $catalogItem->id)
->whereNull('replaced_by_variant_id')
->lockForUpdate() ->lockForUpdate()
->get(); ->get();

View File

@@ -11,6 +11,8 @@ use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService; use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Event\Enums\EventDateStatus;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -36,8 +38,10 @@ class FoodService
->with([ ->with([
'variants.catalogItem', 'variants.catalogItem',
'variants.inventory', 'variants.inventory',
'variants.eventDate', 'variants.eventDate.rescheduledTo',
'variants.eventDates', 'variants.eventDate.changeHistory.destinationEventDate',
'variants.eventDates.rescheduledTo',
'variants.eventDates.changeHistory.destinationEventDate',
'variants.definitions.itemAttribute.attribute', 'variants.definitions.itemAttribute.attribute',
]) ])
->first(); ->first();
@@ -55,11 +59,16 @@ class FoodService
$food->variants()->whereNull('precio')->update(['precio' => $food->precio]); $food->variants()->whereNull('precio')->update(['precio' => $food->precio]);
$existingVariants = $food->variants() $existingVariants = $food->variants()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->with(['inventory', 'eventDate', 'eventDates', 'definitions.itemAttribute.attribute']) ->with(['inventory', 'eventDate', 'eventDates', 'definitions.itemAttribute.attribute'])
->lockForUpdate() ->lockForUpdate()
->get(); ->get()
->filter(fn (Variant $variant): bool => $this->hasCurrentDate($variant))
->values();
$resolvedVariants = $this->resolveVariants($variants, $attributes); $resolvedVariants = $this->resolveVariants($variants, $attributes);
$this->validateCurrentEventDates($tenant, $resolvedVariants);
$this->validateCombinations($resolvedVariants, $existingVariants); $this->validateCombinations($resolvedVariants, $existingVariants);
foreach ($resolvedVariants as $index => $data) { foreach ($resolvedVariants as $index => $data) {
@@ -80,7 +89,13 @@ class FoodService
} }
} }
$minimumPrice = $food->variants()->min('precio'); $minimumPrice = $food->variants()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->with(['eventDate', 'eventDates'])
->get()
->filter(fn (Variant $variant): bool => $this->hasCurrentDate($variant))
->min('precio');
if ($minimumPrice !== null) { if ($minimumPrice !== null) {
$food->update(['precio' => $minimumPrice]); $food->update(['precio' => $minimumPrice]);
} }
@@ -88,22 +103,103 @@ class FoodService
return $food->fresh()->load([ return $food->fresh()->load([
'variants.catalogItem', 'variants.catalogItem',
'variants.inventory', 'variants.inventory',
'variants.eventDate', 'variants.eventDate.rescheduledTo',
'variants.eventDates', 'variants.eventDate.changeHistory.destinationEventDate',
'variants.eventDates.rescheduledTo',
'variants.eventDates.changeHistory.destinationEventDate',
'variants.definitions.itemAttribute.attribute', 'variants.definitions.itemAttribute.attribute',
]); ]);
}); });
} }
/**
* @param array<int, array{id: int, stock: int}> $variants
*/
public function updateHistoricalStock(Tenant $tenant, array $variants): CatalogItem
{
return DB::transaction(function () use ($tenant, $variants): CatalogItem {
$food = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('slug', 'comida')
->lockForUpdate()
->firstOrFail();
$variantIds = collect($variants)->pluck('id')->map(fn ($id): int => (int) $id);
$historicalVariants = $food->variants()
->whereIn('id', $variantIds)
->with(['inventory', 'eventDate', 'eventDates'])
->lockForUpdate()
->get()
->filter(fn (Variant $variant): bool => $this->hasHistoricalDate($variant))
->keyBy('id');
foreach ($variants as $index => $data) {
$variant = $historicalVariants->get((int) $data['id']);
if ($variant === null) {
throw ValidationException::withMessages([
"variants.{$index}.id" => [
'La variante no pertenece al historial de Comida.',
],
]);
}
$stock = (int) $data['stock'];
$inventory = $this->inventoryForHistoricalStockUpdate($variant);
if ($stock < $inventory->reserved_stock) {
throw ValidationException::withMessages([
"variants.{$index}.stock" => [
'El stock no puede ser menor que la cantidad actualmente reservada.',
],
]);
}
$inventory->update(['real_stock' => $stock]);
}
return $this->current($tenant) ?? $food;
});
}
private function inventoryForHistoricalStockUpdate(Variant $variant): Inventory
{
$inventory = Inventory::query()
->whereKey($variant->inventory_id)
->lockForUpdate()
->firstOrFail();
$variantsSharingInventory = Variant::query()
->where('inventory_id', $inventory->getKey())
->orderBy('id')
->lockForUpdate()
->get(['id']);
if ($variantsSharingInventory->count() === 1) {
return $inventory;
}
$historicalInventory = Inventory::query()->create([
'sold_units' => $inventory->sold_units,
'refunded_units' => $inventory->refunded_units,
'reserved_stock' => 0,
'real_stock' => $inventory->real_stock,
]);
$variant->update(['inventory_id' => $historicalInventory->getKey()]);
return $historicalInventory;
}
public function delete(Tenant $tenant, int $foodId): void public function delete(Tenant $tenant, int $foodId): void
{ {
$variant = Variant::query() $variant = Variant::query()
->whereKey($foodId) ->whereKey($foodId)
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->with(['eventDate', 'eventDates'])
->whereHas('catalogItem', fn ($query) => $query ->whereHas('catalogItem', fn ($query) => $query
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
->where('slug', 'comida')) ->where('slug', 'comida'))
->firstOrFail(); ->firstOrFail();
abort_unless($this->hasCurrentDate($variant), 404);
$this->catalogService->deleteVariant($variant); $this->catalogService->deleteVariant($variant);
} }
@@ -225,6 +321,30 @@ class FoodService
return $option; return $option;
} }
/** @param array<int, array<string, mixed>> $variants */
private function validateCurrentEventDates(Tenant $tenant, array $variants): void
{
$eventDates = EventDate::query()
->where('tenant_code', $tenant->codigo)
->whereIn('id', collect($variants)->pluck('event_date_id')->unique())
->get()
->keyBy('id');
foreach ($variants as $index => $variant) {
$eventDate = $eventDates->get($variant['event_date_id']);
if ($eventDate !== null && $this->isCurrentStatus($eventDate->status)) {
continue;
}
throw ValidationException::withMessages([
"variants.{$index}.event_date_id" => [
'La fecha seleccionada ya no está disponible.',
],
]);
}
}
/** /**
* @param array<int, array<string, mixed>> $incoming * @param array<int, array<string, mixed>> $incoming
* @param Collection<int, Variant> $existing * @param Collection<int, Variant> $existing
@@ -329,4 +449,25 @@ class FoodService
mb_strtolower(trim($service)), mb_strtolower(trim($service)),
]); ]);
} }
private function hasCurrentDate(Variant $variant): bool
{
$status = $variant->selectedEventDates()->first()?->status;
return $status === null || $this->isCurrentStatus($status);
}
private function hasHistoricalDate(Variant $variant): bool
{
return in_array(
$variant->selectedEventDates()->first()?->status,
[EventDateStatus::Rescheduled, EventDateStatus::Suspended, EventDateStatus::Completed],
true,
);
}
private function isCurrentStatus(EventDateStatus $status): bool
{
return in_array($status, [EventDateStatus::Scheduled, EventDateStatus::InProgress], true);
}
} }

View File

@@ -39,6 +39,9 @@ Route::prefix('v1/adminapp/tenant')
Route::post('foods', [FoodController::class, 'store']) Route::post('foods', [FoodController::class, 'store'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida') ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
->name('adminapp.fiesta-futbol-infantil.foods.store'); ->name('adminapp.fiesta-futbol-infantil.foods.store');
Route::patch('foods/history-stock', [FoodController::class, 'updateHistoricalStock'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
->name('adminapp.fiesta-futbol-infantil.foods.history-stock.update');
Route::delete('foods/{food}', [FoodController::class, 'destroy']) Route::delete('foods/{food}', [FoodController::class, 'destroy'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida') ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
->name('adminapp.fiesta-futbol-infantil.foods.destroy'); ->name('adminapp.fiesta-futbol-infantil.foods.destroy');

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,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,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

@@ -4,6 +4,7 @@ namespace App\Domains\Forms\Services;
use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\AttributeOption; use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Event\Enums\EventDateStatus;
use App\Domains\Event\Models\EventDate; use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
@@ -27,7 +28,17 @@ class FoodFormService
->keyBy('codigo'); ->keyBy('codigo');
return [ return [
'event_dates' => $tenant->eventDates()->with('validityTime')->get(), '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, 'schedules' => $attributes->get('horario')?->options ?? new Collection,
'services' => $attributes->get('servicio')?->options ?? new Collection, 'services' => $attributes->get('servicio')?->options ?? new Collection,
]; ];

View File

@@ -124,11 +124,10 @@ class TicketFilterFormService
'required' => false, 'required' => false,
'default' => null, 'default' => null,
'placeholder' => 'Estado', 'placeholder' => 'Estado',
'options' => [ 'options' => array_values(array_filter(
['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'], Ticket::statusOptions(),
['value' => Ticket::STATUS_USED, 'label' => 'Usado'], fn (array $option): bool => $option['value'] !== Ticket::STATUS_CANCELLED,
['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'], )),
],
], ],
]; ];
} }

View File

@@ -236,11 +236,7 @@ class TicketFormService
?: $left['label'] <=> $right['label']); ?: $left['label'] <=> $right['label']);
return [ return [
'statuses' => [ 'statuses' => Ticket::statusOptions(),
['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'],
['value' => Ticket::STATUS_USED, 'label' => 'Usado'],
['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'],
],
'categories' => array_values(array_map( 'categories' => array_values(array_map(
fn (array $category): array => [ fn (array $category): array => [
'value' => $category['value'], 'value' => $category['value'],

View File

@@ -1,5 +1,6 @@
<?php <?php
use App\Domains\Forms\Controllers\AdminApp\EntryFormController;
use App\Domains\Forms\Controllers\AdminApp\EventFormController; use App\Domains\Forms\Controllers\AdminApp\EventFormController;
use App\Domains\Forms\Controllers\AdminApp\FoodFormController; use App\Domains\Forms\Controllers\AdminApp\FoodFormController;
use App\Domains\Forms\Controllers\AdminApp\MerchandiseFormController; use App\Domains\Forms\Controllers\AdminApp\MerchandiseFormController;
@@ -22,6 +23,10 @@ Route::prefix('v1/adminapp/forms')
'fiesta-futbol-infantil/ticket', 'fiesta-futbol-infantil/ticket',
TicketFormController::class TicketFormController::class
); );
Route::get(
'fiesta-futbol-infantil/entry',
EntryFormController::class
);
Route::get( Route::get(
'fiesta-futbol-infantil/merchandise', 'fiesta-futbol-infantil/merchandise',
MerchandiseFormController::class MerchandiseFormController::class

View File

@@ -4,8 +4,9 @@ namespace App\Domains\Integration\Controllers;
use App\Domains\Client\Models\Client; use App\Domains\Client\Models\Client;
use App\Domains\Integration\Models\Integration; use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Requests\StoreClientIntegrationRequest; use App\Domains\Integration\Requests\ConfigureIntegrationRequest;
use App\Domains\Integration\Services\ClientIntegrationService; use App\Domains\Integration\Services\ClientIntegrationService;
use App\Domains\Integration\Services\IntegrationAssociationService;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -35,7 +36,7 @@ class ClientIntegrationController extends Controller
} }
public function store( public function store(
StoreClientIntegrationRequest $request, ConfigureIntegrationRequest $request,
Client $client, Client $client,
string $integrationCode, string $integrationCode,
): JsonResponse { ): JsonResponse {
@@ -61,4 +62,11 @@ class ClientIntegrationController extends Controller
], 400); ], 400);
} }
} }
public function destroy(Client $client, string $integrationCode, IntegrationAssociationService $service)
{
$service->detach($client, $integrationCode);
return response()->noContent();
}
} }

View File

@@ -35,6 +35,7 @@ class IntegrationController extends Controller
public function destroy(Integration $integration) public function destroy(Integration $integration)
{ {
abort_if($integration->instances()->exists(), 409, 'Delete the integration instances first.');
$integration->delete(); $integration->delete();
return response()->noContent(); 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\ConfigureIntegrationRequest;
use App\Domains\Integration\Resources\IntegrationAssociationResource;
use App\Domains\Integration\Services\IntegrationAssociationService;
use App\Domains\Tenant\Models\WebsiteType;
use App\Http\Controllers\Controller;
class WebsiteTypeIntegrationController extends Controller
{
public function __construct(private readonly IntegrationAssociationService $service) {}
public function index(WebsiteType $websiteType)
{
return IntegrationAssociationResource::collection($websiteType->integrations()->with('integrationInstance')->get());
}
public function show(WebsiteType $websiteType, string $integrationCode)
{
return new IntegrationAssociationResource($websiteType->integrations()->with('integrationInstance')->where('integration_code', $integrationCode)->firstOrFail());
}
public function store(ConfigureIntegrationRequest $request, WebsiteType $websiteType, string $integrationCode)
{
$integration = Integration::query()->where('integration_code', $integrationCode)->firstOrFail();
return new IntegrationAssociationResource($this->service->configure(
$websiteType,
$integration,
$request->validated('integration_data'),
));
}
public function destroy(WebsiteType $websiteType, string $integrationCode)
{
$this->service->detach($websiteType, $integrationCode);
return response()->noContent();
}
}

View File

@@ -3,22 +3,15 @@
namespace App\Domains\Integration\Models; namespace App\Domains\Integration\Models;
use App\Domains\Client\Models\Client; use App\Domains\Client\Models\Client;
use App\Domains\Integration\Casts\EncryptedIntegrationData;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ClientIntegration extends Model class ClientIntegration extends Model
{ {
protected $hidden = ['integration_data'];
protected $fillable = [ protected $fillable = [
'client_id', 'client_id',
'integration_code', 'integration_code',
'integration_data', 'integration_instance_id',
];
protected $casts = [
'integration_data' => EncryptedIntegrationData::class,
]; ];
/** @return BelongsTo<Client, $this> */ /** @return BelongsTo<Client, $this> */
@@ -32,4 +25,10 @@ class ClientIntegration extends Model
{ {
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code'); return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
} }
/** @return BelongsTo<IntegrationInstance, $this> */
public function integrationInstance(): BelongsTo
{
return $this->belongsTo(IntegrationInstance::class);
}
} }

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Integration\Models; namespace App\Domains\Integration\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Integration extends Model class Integration extends Model
{ {
@@ -13,16 +14,29 @@ class Integration extends Model
'name', 'name',
'url', 'url',
'integration_data_schema', 'integration_data_schema',
'requires_client_configuration', 'requires_configuration',
]; ];
protected $casts = [ protected $casts = [
'integration_data_schema' => 'array', 'integration_data_schema' => 'array',
'requires_client_configuration' => 'boolean', 'requires_configuration' => 'boolean',
]; ];
public function clientIntegrations() /** @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 $this->hasMany(ClientIntegration::class, 'integration_code', 'integration_code');
} }
/** @return HasMany<WebsiteTypeIntegration, $this> */
public function websiteTypeIntegrations(): HasMany
{
return $this->hasMany(WebsiteTypeIntegration::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<WebsiteTypeIntegration, $this> */
public function websiteTypeIntegrations(): HasMany
{
return $this->hasMany(WebsiteTypeIntegration::class);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Integration\Models;
use App\Domains\Tenant\Models\WebsiteType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WebsiteTypeIntegration extends Model
{
protected $fillable = ['website_type_code', 'integration_code', 'integration_instance_id'];
/** @return BelongsTo<WebsiteType, $this> */
public function websiteType(): BelongsTo
{
return $this->belongsTo(WebsiteType::class, '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,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

@@ -6,20 +6,19 @@ use App\Domains\Integration\Models\Integration;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class StoreClientIntegrationRequest extends FormRequest class ConfigureIntegrationRequest extends FormRequest
{ {
protected ?Integration $integrationModel = null; protected ?Integration $integrationModel = null;
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()?->can('manage', Integration::class) ?? false;
} }
protected function prepareForValidation(): void protected function prepareForValidation(): void
{ {
$integrationCode = $this->route('integration_code');
$this->integrationModel = Integration::query() $this->integrationModel = Integration::query()
->where('integration_code', $integrationCode) ->where('integration_code', $this->route('integration_code'))
->first(); ->first();
if (! $this->integrationModel) { if (! $this->integrationModel) {
@@ -31,7 +30,7 @@ class StoreClientIntegrationRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
$rules = []; $rules = ['integration_data' => ['present', 'array']];
foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) { foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) {
$rules['integration_data.'.$field] = $rule; $rules['integration_data.'.$field] = $rule;

View File

@@ -18,7 +18,7 @@ class StoreIntegrationRequest extends FormRequest
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'url' => ['nullable', 'url', 'max:255'], 'url' => ['nullable', 'url', 'max:255'],
'integration_data_schema' => ['nullable', 'array'], 'integration_data_schema' => ['nullable', 'array'],
'requires_client_configuration' => ['sometimes', 'boolean'], 'requires_configuration' => ['sometimes', 'boolean'],
]; ];
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Integration\Requests; namespace App\Domains\Integration\Requests;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateIntegrationRequest extends FormRequest class UpdateIntegrationRequest extends FormRequest
{ {
@@ -19,9 +20,8 @@ class UpdateIntegrationRequest extends FormRequest
'name' => ['sometimes', 'required', 'string', 'max:255'], 'name' => ['sometimes', 'required', 'string', 'max:255'],
'url' => ['nullable', 'url', 'max:255'], 'url' => ['nullable', 'url', 'max:255'],
'integration_data_schema' => ['nullable', 'array'], 'integration_data_schema' => ['nullable', 'array'],
'requires_client_configuration' => ['sometimes', 'boolean'], 'requires_configuration' => ['sometimes', 'boolean'],
// the code shouldn't ideally be updatable, but if it is: 'integration_code' => ['sometimes', 'required', 'string', Rule::in([$integration->integration_code])],
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,'.($integration->id ?? '')],
]; ];
} }
} }

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),
'website_type_code' => $this->when(isset($this->website_type_code), $this->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

@@ -5,6 +5,8 @@ namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client; use App\Domains\Client\Models\Client;
use App\Domains\Integration\Models\ClientIntegration; use App\Domains\Integration\Models\ClientIntegration;
use App\Domains\Integration\Models\Integration; use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\IntegrationInstance;
use App\Domains\Integration\Models\WebsiteTypeIntegration;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Exception; use Exception;
use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\PendingRequest;
@@ -32,9 +34,9 @@ abstract class BaseIntegrationService
protected ?Integration $integration = null; protected ?Integration $integration = null;
/** /**
* The client-owned integration configuration. * The effective integration instance.
*/ */
protected ?ClientIntegration $clientIntegration = null; protected ?IntegrationInstance $integrationInstance = null;
/** /**
* Set the integration code. * Set the integration code.
@@ -85,7 +87,7 @@ abstract class BaseIntegrationService
} }
/** /**
* Load the integration definition and its client-owned configuration. * Load the integration definition and its effective instance configuration.
* *
* @throws Exception * @throws Exception
*/ */
@@ -95,6 +97,7 @@ abstract class BaseIntegrationService
throw new Exception('Integration code is not set.'); throw new Exception('Integration code is not set.');
} }
$this->integrationInstance = null;
$this->integration = Integration::where('integration_code', $this->integrationCode)->first(); $this->integration = Integration::where('integration_code', $this->integrationCode)->first();
if (! $this->integration) { if (! $this->integration) {
throw new Exception("Integration with code '{$this->integrationCode}' not found."); throw new Exception("Integration with code '{$this->integrationCode}' not found.");
@@ -104,11 +107,18 @@ abstract class BaseIntegrationService
throw new Exception('Client context is not set.'); throw new Exception('Client context is not set.');
} }
$this->clientIntegration = ClientIntegration::where('client_id', $this->clientContext->id) $this->integrationInstance = ClientIntegration::with('integrationInstance')->where('client_id', $this->clientContext->id)
->where('integration_code', $this->integrationCode) ->where('integration_code', $this->integrationCode)
->first(); ->first()?->integrationInstance;
if (! $this->clientIntegration && $this->integration->requires_client_configuration) { if (! $this->integrationInstance && $this->tenant?->website_type_code) {
$this->integrationInstance = WebsiteTypeIntegration::with('integrationInstance')
->where('website_type_code', $this->tenant->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."); throw new Exception("Client '{$this->clientContext->code}' does not have integration '{$this->integrationCode}' configured.");
} }
} }
@@ -131,15 +141,15 @@ abstract class BaseIntegrationService
} }
/** /**
* Get an integration setting from the client-owned configuration. * Get an integration setting from the effective instance configuration.
*/ */
protected function getIntegrationSetting(string $key, mixed $default = null): mixed protected function getIntegrationSetting(string $key, mixed $default = null): mixed
{ {
if (! $this->clientIntegration || ! $this->clientIntegration->integration_data) { if (! $this->integrationInstance || ! $this->integrationInstance->integration_data) {
return $default; return $default;
} }
return $this->clientIntegration->integration_data[$key] ?? $default; return $this->integrationInstance->integration_data[$key] ?? $default;
} }
/** /**

View File

@@ -13,6 +13,7 @@ class ClientIntegrationService
public function getClientIntegration(Client $client, string $integrationCode): ?ClientIntegration public function getClientIntegration(Client $client, string $integrationCode): ?ClientIntegration
{ {
return $client->integrations() return $client->integrations()
->with(['integration', 'integrationInstance'])
->where('integration_code', $integrationCode) ->where('integration_code', $integrationCode)
->first(); ->first();
} }
@@ -20,7 +21,7 @@ class ClientIntegrationService
/** @return Collection<int, ClientIntegration> */ /** @return Collection<int, ClientIntegration> */
public function getAllForClient(Client $client): Collection public function getAllForClient(Client $client): Collection
{ {
return $client->integrations()->with('integration')->get(); return $client->integrations()->with(['integration', 'integrationInstance'])->get();
} }
public function updateOrCreateIntegration( public function updateOrCreateIntegration(
@@ -29,13 +30,7 @@ class ClientIntegrationService
array $data, array $data,
): ClientIntegration { ): ClientIntegration {
return DB::transaction(function () use ($client, $integration, $data): ClientIntegration { return DB::transaction(function () use ($client, $integration, $data): ClientIntegration {
$clientIntegration = ClientIntegration::query()->updateOrCreate( $clientIntegration = app(IntegrationAssociationService::class)->configure($client, $integration, $data);
[
'client_id' => $client->id,
'integration_code' => $integration->integration_code,
],
['integration_data' => $data],
);
$service = $this->resolveService($integration->integration_code); $service = $this->resolveService($integration->integration_code);
$service?->forClient($client)->onSetup(); $service?->forClient($client)->onSetup();

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\WebsiteTypeIntegration;
use App\Domains\Tenant\Models\WebsiteType;
use Illuminate\Support\Facades\DB;
class IntegrationAssociationService
{
public function configure(Client|WebsiteType $owner, Integration $integration, array $integrationData): ClientIntegration|WebsiteTypeIntegration
{
return DB::transaction(function () use ($owner, $integration, $integrationData): ClientIntegration|WebsiteTypeIntegration {
$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|WebsiteType $owner, string $code, IntegrationInstance $instance): ClientIntegration|WebsiteTypeIntegration
{
return DB::transaction(function () use ($owner, $code, $instance): ClientIntegration|WebsiteTypeIntegration {
$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|WebsiteType $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|WebsiteType $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

@@ -29,7 +29,7 @@ class MailService extends BaseIntegrationService
private ?Mailer $mailer = null; private ?Mailer $mailer = null;
private bool $usesClientMailer = false; private bool $usesInstanceMailer = false;
public function __construct(?MailFactory $mailFactory = null) public function __construct(?MailFactory $mailFactory = null)
{ {
@@ -40,12 +40,12 @@ class MailService extends BaseIntegrationService
{ {
parent::forTenant($tenantCode); parent::forTenant($tenantCode);
if ($this->clientIntegration) { if ($this->integrationInstance) {
$this->mailer = $this->resolveMailer(); $this->mailer = $this->resolveMailer();
$this->usesClientMailer = true; $this->usesInstanceMailer = true;
} else { } else {
$this->mailer = $this->mailFactory->mailer(); $this->mailer = $this->mailFactory->mailer();
$this->usesClientMailer = false; $this->usesInstanceMailer = false;
} }
return $this; return $this;
@@ -56,12 +56,12 @@ class MailService extends BaseIntegrationService
parent::forClient($client); parent::forClient($client);
$this->tenant = $this->clientContext?->tenants()->first(); $this->tenant = $this->clientContext?->tenants()->first();
if ($this->clientIntegration) { if ($this->integrationInstance) {
$this->mailer = $this->resolveMailer(); $this->mailer = $this->resolveMailer();
$this->usesClientMailer = true; $this->usesInstanceMailer = true;
} else { } else {
$this->mailer = $this->mailFactory->mailer(); $this->mailer = $this->mailFactory->mailer();
$this->usesClientMailer = false; $this->usesInstanceMailer = false;
} }
return $this; return $this;
@@ -122,8 +122,8 @@ class MailService extends BaseIntegrationService
public function mailerName(): string public function mailerName(): string
{ {
return $this->usesClientMailer return $this->usesInstanceMailer
? 'client-smtp' ? 'integration-smtp'
: (string) config('mail.default'); : (string) config('mail.default');
} }
@@ -187,7 +187,7 @@ class MailService extends BaseIntegrationService
private function resolveMailer(): Mailer private function resolveMailer(): Mailer
{ {
$data = $this->clientIntegration?->integration_data; $data = $this->integrationInstance?->integration_data;
if (! is_array($data)) { if (! is_array($data)) {
throw new InvalidArgumentException('La configuración SMTP del cliente no es válida.'); throw new InvalidArgumentException('La configuración SMTP del cliente no es válida.');
@@ -205,7 +205,7 @@ class MailService extends BaseIntegrationService
} }
$mailer = $this->mailFactory->build([ $mailer = $this->mailFactory->build([
'name' => 'client-smtp-'.$this->clientContext?->id, 'name' => 'integration-smtp-'.$this->integrationInstance?->id,
'transport' => 'smtp', 'transport' => 'smtp',
'scheme' => $data['MAIL_SCHEME'] ?? null, 'scheme' => $data['MAIL_SCHEME'] ?? null,
'host' => $data['MAIL_HOST'], 'host' => $data['MAIL_HOST'],

View File

@@ -45,11 +45,11 @@ class TelepagosIntegrationService extends BaseIntegrationService
*/ */
public function getToken(): string public function getToken(): string
{ {
if (! $this->clientIntegration || ! $this->clientContext) { if (! $this->integrationInstance) {
throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.'); throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.');
} }
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}"; $cacheKey = $this->integrationInstance->tokenCacheKey();
$token = Cache::get($cacheKey); $token = Cache::get($cacheKey);
@@ -94,7 +94,7 @@ class TelepagosIntegrationService extends BaseIntegrationService
// Calculate TTL and subtract a buffer of 60 seconds // Calculate TTL and subtract a buffer of 60 seconds
$ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60); $ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60);
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}"; $cacheKey = $this->integrationInstance->tokenCacheKey();
Cache::put($cacheKey, $token, $ttlSeconds); Cache::put($cacheKey, $token, $ttlSeconds);
return $token; return $token;
@@ -220,11 +220,11 @@ class TelepagosIntegrationService extends BaseIntegrationService
*/ */
public function clearToken(): void public function clearToken(): void
{ {
if (! $this->clientContext) { if (! $this->integrationInstance) {
return; return;
} }
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}"; $cacheKey = $this->integrationInstance->tokenCacheKey();
Cache::forget($cacheKey); Cache::forget($cacheKey);
} }

View File

@@ -1,33 +1,48 @@
# Dominio Integration # Dominio Integration
## Propósito ## Modelo
Gestiona integraciones externas disponibles y su configuración por cliente. Un cliente puede agrupar múltiples tenants que comparten las mismas credenciales. Incluye correo y pagos mediante Telepagos. - `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 `WebsiteTypeIntegration`: asociaciones a instancias. La clave compuesta verifica el código de la instancia y la unicidad permite una instancia por integración y propietario.
## Modelo y seguridad 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.
- `Integration`: definición global de una integración. ## Resolución
- `ClientIntegration`: configuración y credenciales de una integración para un cliente.
- `EncryptedIntegrationData`: cast que protege los datos sensibles persistidos.
- `ClientIntegrationService`: consulta y configura integraciones del cliente.
## Servicios externos `BaseIntegrationService::forTenant()` busca primero la asociación del cliente y después la del tipo de sitio 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.
- `BaseIntegrationService`: resuelve el cliente desde el tenant operativo y carga exclusivamente la configuración del cliente. `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`.
- `MailService`: envío de correo usando la integración configurada.
- `TelepagosIntegrationService`: autenticación, caché de token, generación de QR y consulta de cobros.
- `TelepagosWebhookService`: procesa notificaciones recibidas desde Telepagos.
## Endpoints 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.
- CRUD global bajo `/integrations`. ## Administración
- Consulta y configuración por cliente bajo `/clients/{client}/integrations`.
- `POST /webhooks/telepagos/{client}` para notificaciones del proveedor.
## Logging de Telepagos 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.
Los eventos de autenticación, QR, consultas de cuenta y procesamiento de webhooks se escriben en el canal diario `telepagos`, separado del log general. Los archivos se generan en `storage/logs/telepagos/telepagos-YYYY-MM-DD.log`; el nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Tokens y credenciales se eliminan del contexto antes de registrar respuestas del proveedor. | 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 | `/website-types/{codigo}/integrations[/{integration_code}]` | Consultar asociaciones del tipo de sitio. |
| PUT | `/website-types/{codigo}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del tipo de sitio. |
| DELETE | `/website-types/{codigo}/integrations/{integration_code}` | Desvincular. |
## Dependencias y reglas 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.
Se integra con `Client`, `Tenant` y con el checkout de `Purchase`. `Notification` utiliza `MailService`. El tenant conserva el contexto operativo y de branding, pero nunca es dueño de credenciales. Las credenciales no se exponen en respuestas ni logs; los webhooks deben validar su contrato antes de alterar una compra. 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

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

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,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

@@ -11,6 +11,7 @@ use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Services\TicketPdfService; use App\Domains\Ticket\Services\TicketPdfService;
use App\Domains\Ticket\Services\TicketPresentationResolver; use App\Domains\Ticket\Services\TicketPresentationResolver;
use App\Domains\Ticket\Services\TicketValidityResolver;
use Closure; use Closure;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -21,16 +22,25 @@ class NotificationMailService
public function __construct( public function __construct(
private readonly MailService $mailService, private readonly MailService $mailService,
private readonly TicketPdfService $ticketPdfService, private readonly TicketPdfService $ticketPdfService,
private readonly IdempotentEmailDeliveryService $emailDeliveryService,
) {} ) {}
public function sendWelcome(int $userId, string $tenantCode): void public function sendWelcome(int $userId, string $tenantCode): void
{ {
$this->sendLogged('welcome', [ $context = [
'user_id' => $userId, 'user_id' => $userId,
'tenant_code' => $tenantCode, 'tenant_code' => $tenantCode,
], function () use ($userId, $tenantCode): array { ];
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail(); $tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
$user = User::query()->findOrFail($userId); $user = User::query()->findOrFail($userId);
$this->sendIdempotently(
"welcome:{$tenantCode}:{$userId}",
'welcome',
$tenantCode,
$context,
$user->email,
function () use ($user, $tenant, $tenantCode): array {
$brand = $tenant->websiteType ?? $tenant; $brand = $tenant->websiteType ?? $tenant;
$tenantUrl = 'https://'.$tenant->dominio.$tenant->base_path; $tenantUrl = 'https://'.$tenant->dominio.$tenant->base_path;
@@ -46,7 +56,8 @@ class NotificationMailService
return [ return [
'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type', 'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type',
]; ];
}); },
);
} }
public function sendPasswordResetCode( public function sendPasswordResetCode(
@@ -60,7 +71,6 @@ class NotificationMailService
'channel' => $channel, 'channel' => $channel,
]; ];
$this->sendLogged('password_reset', $context, function () use ($attemptId, $tenantCode, $channel, $context): ?array {
$tenant = Tenant::query() $tenant = Tenant::query()
->with('websiteType') ->with('websiteType')
->where('codigo', $tenantCode) ->where('codigo', $tenantCode)
@@ -76,9 +86,16 @@ class NotificationMailService
'user_id' => $attempt->user_id, 'user_id' => $attempt->user_id,
])); ]));
return null; return;
} }
$this->sendIdempotently(
"password-reset:{$attemptId}",
'password_reset',
$tenantCode,
$context,
$attempt->user->email,
function () use ($attempt, $tenant, $tenantCode, $channel): array {
$recoveryDomain = match ($channel) { $recoveryDomain = match ($channel) {
PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio, PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio,
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain, PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
@@ -130,14 +147,14 @@ class NotificationMailService
'user_id' => $attempt->user_id, 'user_id' => $attempt->user_id,
'recovery_domain_available' => $recoveryDomain !== null, 'recovery_domain_available' => $recoveryDomain !== null,
]; ];
}); },
);
} }
public function sendPurchaseConfirmed(int $purchaseId): void public function sendPurchaseConfirmed(int $purchaseId): void
{ {
$context = ['purchase_id' => $purchaseId]; $context = ['purchase_id' => $purchaseId];
$this->sendLogged('purchase_confirmed', $context, function () use ($purchaseId, $context): ?array {
$purchase = Purchase::query() $purchase = Purchase::query()
->with(['tenant', 'user', 'items']) ->with(['tenant', 'user', 'items'])
->find($purchaseId); ->find($purchaseId);
@@ -148,9 +165,23 @@ class NotificationMailService
'missing_model' => Purchase::class, 'missing_model' => Purchase::class,
])); ]));
return null; 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 */ /** @var Collection<int, Ticket> $tickets */
$tickets = $purchase->tickets() $tickets = $purchase->tickets()
->where('tenant_code', $purchase->tenant_codigo) ->where('tenant_code', $purchase->tenant_codigo)
@@ -167,7 +198,7 @@ class NotificationMailService
$this->mailService $this->mailService
->forTenant($purchase->tenant_codigo) ->forTenant($purchase->tenant_codigo)
->send( ->send(
$this->recipientFor($purchase), $recipient,
"Compra confirmada - Compra #{$purchase->getKey()}", "Compra confirmada - Compra #{$purchase->getKey()}",
view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(), view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(),
attachments: $attachments, attachments: $attachments,
@@ -181,7 +212,215 @@ class NotificationMailService
'ticket_count' => $tickets->count(), 'ticket_count' => $tickets->count(),
'ticket_ids' => $tickets->modelKeys(), '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->websiteType ?? $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->websiteType ?? $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.websiteType', 'user'])
->find($purchaseId);
} }
private function recipientFor(Purchase $purchase): string private function recipientFor(Purchase $purchase): string
@@ -189,6 +428,34 @@ class NotificationMailService
return (string) ($purchase->email ?: $purchase->user?->email); 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 array<string, mixed> $context
* @param Closure(): (array<string, mixed>|null) $send * @param Closure(): (array<string, mixed>|null) $send

View File

@@ -12,7 +12,19 @@ Orquesta notificaciones de negocio por correo a partir de eventos de otros domin
## Componentes ## Componentes
Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail` y `SendPurchaseConfirmedEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`. 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 ## API y dependencias
@@ -25,3 +37,12 @@ No expone rutas HTTP. Consume datos de `Auth`, `Tenant`, `Purchase` y `Ticket`,
- Los correos de cuenta (bienvenida y recuperación de contraseña) usan la identidad visual del `WebsiteType` asociado al tenant, con fallback al tenant si no tiene uno configurado. - Los correos de cuenta (bienvenida y recuperación de contraseña) usan la identidad visual del `WebsiteType` asociado al tenant, 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. - 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. - 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.

Some files were not shown because too many files have changed in this diff Show More