Compare commits

..

No commits in common. "develop" and "main" have entirely different histories.

739 changed files with 2013 additions and 118028 deletions

View file

@ -1,24 +0,0 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[docker-compose.yml]
indent_size = 4
[*.scss]
indent_size = 2
[*.{tsx,ts,cjs}]
indent_size = 2

View file

@ -1,79 +0,0 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
SAML2_SP_CERT_x509=
SAML2_SP_CERT_PRIVATEKEY=
SAML2_CONTACT_TECHNICAL_EMAIL=
SAML2_CONTACT_SUPPORT_EMAIL=
SAML2_DEFAULT_TENANT_UUID=
AWS_ACCESS_KEY_ID=minioadmin
AWS_SECRET_ACCESS_KEY=minioadmin
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=ovoteam
AWS_ENDPOINT=http://127.0.0.1:9000
AWS_URL=http://127.0.0.1:9000/ovoteam
AWS_USE_PATH_STYLE_ENDPOINT=true
VITE_APP_NAME="${APP_NAME}"
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=
MEILISEARCH_KEY=
STORAGE_SERVICE_PREFIX=media

View file

@ -1,4 +0,0 @@
node_modules
public
vendor
resources/ts/components/ui/

View file

@ -1,80 +0,0 @@
module.exports = {
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: 2020,
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
project: "./tsconfig.json",
},
settings: {
react: {
version: "detect",
},
"import/resolver": {
typescript: {
project: "./tsconfig.json",
},
alias: {
map: [
["@", "./resources/ts"],
["&", "./resources/assets"],
],
extensions: [".ts", ".tsx", ".js", ".jsx"],
},
},
},
env: {
browser: true,
es2021: true,
node: true,
},
ignorePatterns: ["resources/ts/components/ui/**"],
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
],
plugins: ["react", "react-hooks", "@typescript-eslint", "import", "prettier"],
rules: {
"no-undef": "off",
"react/display-name": "off",
"react/jsx-key": "off",
"react/jsx-filename-extension": ["error", { extensions: [".tsx", ".jsx"] }],
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"react/require-default-props": "off",
"import/extensions": [
"error",
"ignorePackages",
{
ts: "never",
tsx: "never",
js: "never",
jsx: "never",
},
],
"import/order": "off",
"prettier/prettier": "error",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
ignoreRestSiblings: true,
},
],
"no-console": [
"warn",
{
allow: ["warn", "error"],
},
],
},
};

11
.gitattributes vendored
View file

@ -1,11 +0,0 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

40
.gitignore vendored
View file

@ -1,40 +0,0 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
Homestead.json
Homestead.yaml
Thumbs.db
.npmrc
# Favicons in public (generated by Vite from resources/favicons)
/public/favicon-back
/public/favicon.*
/public/favicon-*.png
/public/apple-touch-icon*.png
/public/apple-icon-*.png
/public/android-chrome-*.png
/public/android-icon-*.png
/public/web-app-manifest-*.png
/public/ms-icon-*.png
/public/site.webmanifest
/public/manifest.json
/docker-compose.yml

View file

@ -1,221 +1,35 @@
stages: .docker-build:
- install stage: build
- lint image:
- test name: gcr.io/kaniko-project/executor:v1.23.2-debug
- security entrypoint: [ "" ]
- build script:
- deploy - /kaniko/executor --context "${CI_PROJECT_DIR}" --build-arg "GITLAB_USER=${CI_DEPLOY_USER}" --build-arg "GITLAB_PASSWORD=${CI_DEPLOY_PASSWORD}" --dockerfile "${CI_PROJECT_DIR}/Dockerfile" --destination "${IMAGE_TAG}" --cache=true --cache-ttl 24h
allow_failure: false
docker-build:staging:
extends: .docker-build
variables:
IMAGE_TAG: "${CI_REGISTRY_IMAGE}:s-${CI_COMMIT_SHA}"
rules:
- if: $CI_COMMIT_BRANCH == "trunk"
when: manual
allow_failure: true
docker-build:latest:
extends: .docker-build
variables:
IMAGE_TAG: "${CI_REGISTRY_IMAGE}:p-${CI_COMMIT_SHA}"
rules:
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
when: manual
allow_failure: true
include: include:
- project: "ldc-web/shared-ci" - project: 'ldc-web/shared-ci'
ref: "main" ref: 'main'
file: "security/composer.yml" file: 'deploy/deploy.yml'
- project: "ldc-web/shared-ci"
ref: "main"
file: "build/docker-multistage.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "test/test.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "deploy/deploy.yml"
# * LINT -------------------------------------------------------------------- stages:
- build
.php-lint-base: &php-lint-base - deploy
image: $CI_REGISTRY/ldc-web/projets/ldc-web-images-docker/uwil-ci-8.5:latest
variables:
COMPOSER_CACHE_DIR: ".composer-cache"
CACHE_STORE: "array"
DB_CONNECTION: "sqlite"
DB_DATABASE: ":memory:"
cache:
key:
files:
- composer.lock
prefix: "composer"
policy: pull
paths:
- .composer-cache/
- vendor/
before_script:
- composer config gitlab-token.lab.s-web.fr $CI_DEPLOY_USER $CI_DEPLOY_PASSWORD
- composer install --no-scripts --no-interaction --no-ansi --no-progress --prefer-dist
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "develop"
parallel-lint:
<<: *php-lint-base
stage: lint
script:
- vendor/bin/parallel-lint --exclude vendor --exclude storage --exclude bootstrap --exclude node_modules .
allow_failure: false
pint:
<<: *php-lint-base
stage: lint
script:
- vendor/bin/pint --test
allow_failure: false
phpcs:
<<: *php-lint-base
stage: lint
script:
- vendor/bin/phpcs --standard=phpcs.xml --extensions=php --ignore=vendor/*,storage/*,bootstrap/*,node_modules/* --report=junit --report-file=phpcs-report.xml
artifacts:
when: on_failure
expose_as: 'phpcs'
paths:
- phpcs-report.xml
reports:
junit: phpcs-report.xml
allow_failure: false
phpstan:
<<: *php-lint-base
stage: lint
variables:
PHP_MEMORY_LIMIT: "512M"
COMPOSER_CACHE_DIR: ".composer-cache"
CACHE_STORE: "array"
DB_CONNECTION: "sqlite"
DB_DATABASE: ":memory:"
script:
- php -d memory_limit=512M vendor/bin/phpstan --error-format=junit > phpstan-report.xml
artifacts:
when: on_failure
expose_as: 'phpstan'
paths:
- phpstan-report.xml
reports:
junit: phpstan-report.xml
allow_failure: false
eslint:
stage: lint
image: oven/bun:1
variables:
BUN_INSTALL_CACHE_DIR: ".bun-cache"
cache:
key:
files:
- bun.lock
prefix: "bun"
policy: pull
paths:
- .bun-cache/
before_script:
- echo "@ldc-web:registry=https://${CI_SERVER_HOST}/api/v4/packages/npm/" >> .npmrc
- echo "//${CI_SERVER_HOST}/api/v4/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- echo "@uwil:registry=https://lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/" >> .npmrc
- echo "//lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- bun install --no-save
script:
- bun run eslint-ci --quiet --output-file=eslint-junit.xml
artifacts:
when: on_failure
expose_as: 'eslint'
paths:
- eslint-junit.xml
reports:
junit: eslint-junit.xml
allow_failure: false
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "develop"
stylelint:
stage: lint
image: oven/bun:1
variables:
BUN_INSTALL_CACHE_DIR: ".bun-cache"
cache:
key:
files:
- bun.lock
prefix: "bun"
policy: pull
paths:
- .bun-cache/
before_script:
- echo "@ldc-web:registry=https://${CI_SERVER_HOST}/api/v4/packages/npm/" >> .npmrc
- echo "//${CI_SERVER_HOST}/api/v4/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- echo "@uwil:registry=https://lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/" >> .npmrc
- echo "//lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- bun install --no-save
script:
- bun run stylelint-ci --quiet --output-file=stylelint-junit.xml
artifacts:
when: on_failure
expose_as: 'stylelint'
paths:
- stylelint-junit.xml
reports:
junit: stylelint-junit.xml
allow_failure: false
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "develop"
# * TESTS -------------------------------------------------------------------
bun-build-test:
stage: test
image: oven/bun:1
variables:
BUN_INSTALL_CACHE_DIR: ".bun-cache"
cache:
key:
files:
- bun.lock
prefix: "bun"
policy: pull
paths:
- .bun-cache/
before_script:
- echo "@ldc-web:registry=https://${CI_SERVER_HOST}/api/v4/packages/npm/" >> .npmrc
- echo "//${CI_SERVER_HOST}/api/v4/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- echo "@uwil:registry=https://lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/" >> .npmrc
- echo "//lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- bun install --no-save
script:
- timeout 300 bun run prod
artifacts:
expose_as: 'front-build-test'
paths:
- public/build/manifest.json
allow_failure: false
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "trunk" || $CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG != null
# * SECURITY ----------------------------------------------------------------
npm-security:
stage: security
image: oven/bun:1
before_script:
- echo "@ldc-web:registry=https://${CI_SERVER_HOST}/api/v4/packages/npm/" >> .npmrc
- echo "//${CI_SERVER_HOST}/api/v4/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- echo "@uwil:registry=https://lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/" >> .npmrc
- echo "//lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
script:
- bun audit --json > bun-security-checker-report.json
artifacts:
when: on_failure
expose_as: 'bun-security-checker'
paths:
- bun-security-checker-report.json
allow_failure: true
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "trunk" || $CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG != null
# * BUILD -------------------------------------------------------------------
.docker-build:
dependencies: []

View file

@ -1,11 +0,0 @@
## Comportement constaté
## Comportement attendu
## Solution envisagée
## Étapes pour la reproduction du bug
*

View file

@ -1,7 +0,0 @@
## Présentation du besoin
## Implémentation
## Maquettes, documentations...

View file

@ -1,2 +0,0 @@
#!/bin/sh
npx commitlint --config node_modules/@uwil/husky-config/commitlint.config.js --edit $1

View file

@ -1,56 +0,0 @@
{
"extends": [
"stylelint-config-standard-scss",
"stylelint-config-sass-guidelines",
"stylelint-config-idiomatic-order"
],
"plugins": ["stylelint-scss"],
"files": ["**/*.css", "**/*.scss"],
"ignoreFiles": [
"**/vendor/*.css",
"**/vendor/**/*.css",
"**/vendor/*.scss",
"**/vendor/**/*.scss",
"**/vendor/*.sass",
"**/vendor/**/*.sass",
"**/packages/*.css",
"**/packages/**/*.css",
"**/packages/*.scss",
"**/packages/**/*.scss",
"**/packages/*.sass",
"**/packages/**/*.sass"
],
"customSyntax": "postcss-scss",
"rules": {
"declaration-property-value-no-unknown": true,
"selector-max-id": null,
"color-no-invalid-hex": true,
"order/properties-alphabetical-order": null,
"max-nesting-depth": 5,
"selector-max-compound-selectors": 5,
"scss/double-slash-comment-empty-line-before": [
"always",
{
"ignore": [
"between-comments",
"stylelint-commands",
"inside-block"
]
}
],
"at-rule-no-unknown": null,
"scss/at-rule-no-unknown": [
true,
{
"ignoreAtRules": [
"tailwind",
"apply",
"variants",
"responsive",
"screen",
"layer"
]
}
]
}
}

View file

@ -1,5 +0,0 @@
{
"recommendations": [
"emeraldwalk.runonsave"
]
}

109
.vscode/settings.json vendored
View file

@ -1,109 +0,0 @@
{
"workbench.colorCustomizations": {
"activityBar.activeBackground": "#b0981c",
"activityBar.activeBorder": "#15202b",
"activityBar.background": "#b0981c",
"activityBar.foreground": "#15202b",
"activityBar.inactiveForeground": "#15202b99",
"activityBarBadge.background": "#91edde",
"activityBarBadge.foreground": "#15202b",
"sash.hoverBorder": "#b0981c",
"statusBar.background": "#847215",
"statusBar.foreground": "#e7e7e7",
"statusBarItem.hoverBackground": "#b0981c",
"statusBarItem.remoteBackground": "#1fc0a6",
"statusBarItem.remoteForeground": "#e7e7e7",
"titleBar.activeBackground": "#847215",
"titleBar.activeForeground": "#e7e7e7",
"titleBar.inactiveBackground": "#84721599",
"titleBar.inactiveForeground": "#e7e7e799",
"commandCenter.border": "#e7e7e799",
"activityBarTop.activeBackground": "#b0981c",
"activityBarTop.activeBorder": "#15202b",
"activityBarTop.background": "#b0981c",
"activityBarTop.foreground": "#15202b",
"activityBarTop.inactiveForeground": "#15202b99",
"commandCenter.foreground": "#e7e7e7",
"statusBar.debuggingBackground": "#847215",
"statusBar.debuggingForeground": "#e7e7e7",
"statusBar.inactiveBackground": "#847215"
},
"peacock.color": "#847215",
"peacock.remoteColor": "#847215",
"intelephense.environment.phpVersion": "8.5.0",
"intelephense.environment.includePaths": [
"./.intellipest",
"./vendor/fakerphp/faker",
"./vendor/pestphp",
"./vendor/phpunit/phpunit",
"./vendor/laravel"
],
"phpstan.binCommand": [
"${workspaceFolder}/vendor/bin/phpstan"
],
"laravel-pint.enable": true,
"editor.formatOnSave": true,
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact", "html", "vue"],
"[html]": {
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
}
},
"[blade]": {
"editor.formatOnSave": true,
"editor.formatOnType": false
},
"[javascript]": {
"editor.autoIndent": "full",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.detectIndentation": false,
"editor.formatOnSave": true
},
"[scss]": {
"editor.defaultFormatter": "stylelint.vscode-stylelint",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.stylelint": "always"
}
},
"[typescript]": {
"editor.autoIndent": "full",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.detectIndentation": false,
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file"
},
"[php]": {
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file"
},
"[vue]": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
}
},
"conventionalCommits.scopes": [
"migration"
],
"emeraldwalk.runonsave": {
"autoClearConsole": false,
"commands": [
{
"match": "\\.php$",
"isAsync": true,
"cmd": "${workspaceFolder}/vendor/bin/pint ${file}"
}
]
},
"LaravelExtraIntellisense.phpCommand": "docker exec -w /app/srcs -t ovoteam-$(whoami)-apache-php-1 php -r \"{code}\"",
"LaravelExtraIntellisense.basePathForCode": "/app/srcs"
}

View file

@ -1,12 +0,0 @@
# Changelog
All notable changes to `laravel-project` will be documented in this file.
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
- Commits respect [Conventionnal commits](https://www.conventionalcommits.org/en/v1.0.0/) & use [Gitmoji](https://gitmoji.dev/).
## **[v1.0.0] - 01/01/2026**
### Added
- feat: 🎉 working project

View file

@ -1,43 +0,0 @@
## Domaine Métier et Contraintes : Les Produits
Le catalogue de produits repose sur une segmentation précise nécessitant obligatoirement 5 critères d'identification[cite: 1, 2]. Les produits se déclinent sous de très nombreux formats et conditionnements (colis, barquettes, bidons, poches, sachets, seaux, verrines, etc.)[cite: 2].
### Caractéristiques et filtres des produits
| Attribut | Valeurs métier définies | Contraintes de sélection |
| :--- | :--- | :--- |
| **Catégories / Gammes** | Œufs liquides petit conditionnement, Œufs liquides industrie, Œufs liquides pour la grande distribution, Œufs durs, Œufs brouillés, Œufs pochés, Omelettes et Gammes traiteur et desserts[cite: 1, 2]. | 1 seule par produit[cite: 1, 2]. |
| **Technologies** | Frais, Surgelé, Ambiant[cite: 2]. | 1 seule par produit[cite: 2]. |
| **Modes d'élevage** | Plein air, Bio, Sol, Conventionnel et Label Rouge[cite: 1, 2]. | Minimum 1, maximum illimité (X)[cite: 1]. |
| **Métiers (Cibles)** | Hôtellerie, Restauration rapide et snacking, Boulangerie pâtisserie, Restauration collective, Industrie agro-alimentaire, Grande distribution et Restauration à table[cite: 1, 2]. | Minimum 1, maximum illimité (X)[cite: 1]. |
| **Égalim** | Oui, Non[cite: 2]. | Obligatoire ("Oui" ou "Non")[cite: 2]. |
### Contraintes d'affichage (Fiche Produit)
* Les champs dédiés aux valeurs nutritionnelles et le fichier PDF ne doivent pas s'afficher en front-office si aucune information n'est complétée[cite: 1].
* Une section « Consignes de conservation avant ouverture » doit être ajoutée en dessous de la section détails/avantages[cite: 1].
* Une section « Consignes de conservation après ouverture » doit être ajoutée sous la section "avant ouverture", mais ne doit pas s'afficher si elle n'est pas complétée[cite: 1].
* Un slider de recettes doit être implémenté en bas de page avec 9 slides[cite: 1].
* Ce slider doit afficher en premier les recettes associées au produit, puis compléter avec d'autres recettes de manière aléatoire[cite: 1].
* Aucune liaison avec INCO n'est à prévoir (pas de code INCO)[cite: 1].
---
## Domaine Métier et Contraintes : Les Recettes
Les recettes interagissent avec les produits et sont soumises à 4 filtres spécifiques pour faciliter la navigation des utilisateurs[cite: 2].
### Caractéristiques et filtres des recettes
| Attribut | Valeurs métier définies | Contraintes de sélection |
| :--- | :--- | :--- |
| **Gammes de produits** | Identiques aux gammes de produits : Œufs liquides (petit conditionnement, industrie, grande distribution), Œufs durs, brouillés, pochés, Omelettes, traiteur et desserts[cite: 1, 2]. | 1 seule gamme par recette[cite: 1]. |
| **Moments de consommation** | Petit déjeuner / brunch, Déjeuner / dîner, Snacking, Dessert / pâtisserie[cite: 1, 2]. | 1 seul moment par recette[cite: 1]. |
| **Inspirations** | Cuisine du monde, cuisine française, cuisine asiatique, cuisine protéinée, Snacking, Sucré, Brunch[cite: 1, 2]. | 1 seule inspiration par recette[cite: 1]. |
| **Saisons** | Printemps/été, Automne/hiver, Toute l'année[cite: 1, 2]. | 1 seule saison par recette[cite: 1]. |
### Contraintes d'affichage (Fiche Recette)
* Le champ « astuces et conseils » ne doit pas s'afficher en front-office si aucune information n'est complétée[cite: 1].
* Un slider des produits doit être implémenté en bas de page avec 6 slides[cite: 1].
* Ce slider doit afficher en premier les produits associés à la recette, puis afficher d'autres produits en aléatoire pour compléter[cite: 1].

View file

@ -1,55 +1,4 @@
ARG PHP_8_5_TAG=latest FROM registry.s-web.fr/ldc-web/projets/ldc-web-images-docker/ldcweb-production-8.3:170625-101047
FROM registry.s-web.fr/ldc-web/projets/ldc-web-images-docker/uwil-ci-8.5:latest AS vendor
WORKDIR /app
ARG GITLAB_USER
ARG GITLAB_PASSWORD
COPY database/ database/
COPY composer.json composer.json
COPY composer.lock composer.lock
RUN composer config gitlab-token.lab.s-web.fr "${GITLAB_USER}" "${GITLAB_PASSWORD}"
RUN composer install \
--ignore-platform-reqs \
--no-interaction \
--no-plugins \
--no-scripts \
--no-dev \
--prefer-dist
FROM oven/bun:1.4.4-alpine AS frontend
ARG GITLAB_PASSWORD
RUN mkdir -p /app/public/build
WORKDIR /app
COPY package.json bun.lock* postcss.config.js tailwind.config.js tsconfig.json vite.config.ts .eslintrc.cjs .stylelintrc.json /app/
COPY resources/ /app/resources/
COPY --from=vendor /app/vendor/ /app/vendor/
RUN echo "@ldc-web:registry=https://lab.s-web.fr/api/v4/packages/npm/" >> .npmrc && \
echo "//lab.s-web.fr/api/v4/packages/npm/:_authToken=${GITLAB_PASSWORD}" >> .npmrc && \
echo "@uwil:registry=https://lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/" >> .npmrc && \
echo "//lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/:_authToken=${GITLAB_PASSWORD}" >> .npmrc
RUN bun install --frozen-lockfile && bun run prod
ARG PHP_8_5_TAG
FROM registry.s-web.fr/ldc-web/projets/ldc-web-images-docker/uwil-production-8.5:${PHP_8_5_TAG}
ARG USER=www-data ARG USER=www-data
COPY --chown=${USER}:${USER} . /app/srcs COPY --chown=${USER}:${USER} . /app/srcs
COPY --chown=${USER}:${USER} ./resources/favicons/front/ /app/srcs/public/
COPY --chown=${USER}:${USER} ./resources/favicons/back/ /app/srcs/public/back/
COPY --from=vendor --chown=${USER}:${USER} /app/vendor/ /app/srcs/vendor/
COPY --from=frontend --chown=${USER}:${USER} /app/public/build/ /app/srcs/public/build/
USER root
RUN chmod -R 755 /app/srcs/storage
USER ${USER} USER ${USER}

122
README.md
View file

@ -1,33 +1,111 @@
# Table of contents # OVO TEAM
- [Table of contents](#table-of-contents) ## Instalation
- [Frameworks, Platforms and Libraries](#frameworks-platforms-and-libraries)
- [Documentation](#documentation)
- [Changelog](#changelog)
- [Copyright and License](#copyright-and-license)
# Frameworks, Platforms and Libraries Copier `.env` vers `.env.example` et remplir les informations.
<!-- https://github.com/Ileriayo/markdown-badges --> ## Déployer
[![SASS](https://img.shields.io/badge/SASS-hotpink.svg?style=for-the-badge&logo=SASS&logoColor=white)](https://sass-lang.com/) - Apres git pull sur l'envirronnement désiré
[![TailwindCSS](https://img.shields.io/badge/tailwindcss-%2338B2AC.svg?style=for-the-badge&logo=tailwind-css&logoColor=white)](https://tailwindcss.com/) - Envoyer les éléments uploads si nécéssaire (Ne devrais pas étre le cas)
[![Vite](https://img.shields.io/badge/vite-%23646CFF.svg?style=for-the-badge&logo=vite&logoColor=white)](https://vite.dev/) - Executer la partie `Compiler le front`
[![PHP](https://img.shields.io/badge/php-%23777BB4.svg?style=for-the-badge&logo=php&logoColor=white)](https://www.php.net/)
[![Laravel](https://img.shields.io/badge/laravel-%23FF2D20.svg?style=for-the-badge&logo=laravel&logoColor=white)](https://laravel.com/docs/master)
# Documentation ## Faire une mise à jour
```php Commenter dans `public/.htaccess` la partie `# Hide update.php`, puis accéder à ce fichier via la navigateur
composer i/npm i
```
# Changelog ## Fichier uploads
Latest version v1.0.0. Au sein de `public/sites/default/files`
See the [CHANGELOG.md](CHANGELOG.md) file for details. Les dossiers :
# Copyright and License - `inline-images` Pour les contenus insérés dans les WYSIWYG
- les dossiers `20*` comme `2023-05` par exemple pour les media uploadés via la création de pages
- `styles` qui contienent des images uploadés de dimension spécifiques
Copyright (c) 2025 and beyond UWIL. ## Compiler le front
`node_modules/gulp/bin/gulp.js -f public/themes/custom/custtheme/gulpfile.js`
Deactiver `Aggreger les fichiers CSS et javascript` au sein de `/fr/admin/config/development/performance`
Cliquer sur `Effacer tous les caches` si vous reactivez l'option
---
## Original README
Drupal is an open source content management platform supporting a variety of
websites ranging from personal weblogs to large community-driven websites. For
more information, visit the Drupal website, [Drupal.org][Drupal.org], and join
the [Drupal community][Drupal community].
## Contributing
Drupal is developed on [Drupal.org][Drupal.org], the home of the international
Drupal community since 2001!
[Drupal.org][Drupal.org] hosts Drupal's [GitLab repository][GitLab repository],
its [issue queue][issue queue], and its [documentation][documentation]. Before
you start working on code, be sure to search the [issue queue][issue queue] and
create an issue if your aren't able to find an existing issue.
Every issue on Drupal.org automatically creates a new community-accessible fork
that you can contribute to. Learn more about the code contribution process on
the [Issue forks & merge requests page][issue forks].
## Usage
For a brief introduction, see [USAGE.txt](/core/USAGE.txt). You can also find
guides, API references, and more by visiting Drupal's [documentation
page][documentation].
You can quickly extend Drupal's core feature set by installing any of its
[thousands of free and open source modules][modules]. With Drupal and its
module ecosystem, you can often build most or all of what your project needs
before writing a single line of code.
## Changelog
Drupal keeps detailed [change records][changelog]. You can search Drupal's
changes for a record of every notable breaking change and new feature since
2011.
## Security
For a list of security announcements, see the [Security advisories
page][Security advisories] (available as [an RSS feed][security RSS]). This
page also describes how to subscribe to these announcements via email.
For information about the Drupal security process, or to find out how to report
a potential security issue to the Drupal security team, see the [Security team
page][security team].
## Need a helping hand?
Visit the [Support page][support] or browse [over a thousand Drupal
providers][service providers] offering design, strategy, development, and
hosting services.
## Legal matters
Know your rights when using Drupal by reading Drupal core's
[license](/core/LICENSE.txt).
Learn about the [Drupal trademark and logo policy here][trademark].
[Drupal.org]: https://www.drupal.org
[Drupal community]: https://www.drupal.org/community
[GitLab repository]: https://git.drupalcode.org/project/drupal
[issue queue]: https://www.drupal.org/project/issues/drupal
[issue forks]: https://www.drupal.org/drupalorg/docs/gitlab-integration/issue-forks-merge-requests
[documentation]: https://www.drupal.org/documentation
[changelog]: https://www.drupal.org/list-changes/drupal
[modules]: https://www.drupal.org/project/project_module
[security advisories]: https://www.drupal.org/security
[security RSS]: https://www.drupal.org/security/rss.xml
[security team]: https://www.drupal.org/drupal-security-team
[service providers]: https://www.drupal.org/drupal-services
[support]: https://www.drupal.org/support
[trademark]: https://www.drupal.com/trademark

32
RGPD.md
View file

@ -1,32 +0,0 @@
# RGPD
## Site
### En base de données
| Table | Colonne | Contenu | Utilisation |
| ----- | ------- | ------- | ----------- |
| activity_logs | user_id | Identifiant de l'utilisateur (back) | Tracer les modifications dans le BO |
| --- | --- | --- | --- |
| users | first_name | Prénom | Affichage à différents endroits du BO |
| users | last_name | Nom | Affichage à différents endroits du BO |
| users | email | Adresse mail | Identifiant, sert à la connexion au BO |
### Formulaire de contact (marketing)
| Donnée | Contenu |
| ------ | ------- |
| Nom | Nom de la personne |
| Prénom | Prénom de la personne |
| Téléphone | Numéro de téléphone de la personne |
| Adresse E-mail | Adresse email de la personne |
| Nom de l'entreprise | Nom de l'entreprise de la personne |
| Secteur d'activité | Secteur d'activité de la personne |
## Cookies
| Service | Utilité |
| ------- | ------- |
| Matomo | Mesure d'audience |
| Google Recaptcha | Empêcher la soumission de formulaire par des robots |
| Youtube | Visionner du contenu disponible sur la plateforme Youtube |

File diff suppressed because it is too large Load diff

View file

@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Uwil\Storage\Lib\ValueObjects\StorageFilePath;
/** @implements CastsAttributes<StorageFilePath|null, StorageFilePath|string|null> */
class FilePathCast implements CastsAttributes
{
/** @param array<string, mixed> $attributes */
public function get(Model $model, string $key, mixed $value, array $attributes): ?StorageFilePath
{
if ($value === null) {
return null;
}
if (! is_string($value)) {
throw new \InvalidArgumentException('The given value is not a valid file path string.');
}
return new StorageFilePath($value);
}
/** @param array<string, mixed> $attributes */
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if ($value === null) {
return null;
}
if ($value instanceof StorageFilePath) {
return $value->value;
}
if (is_string($value)) {
return (new StorageFilePath($value))->value;
}
throw new \InvalidArgumentException('The given value is not a valid StorageFilePath instance or path string.');
}
}

View file

@ -1,136 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Casts;
use Domain\Models\ValueObjects\Products\NutritionalValue;
use Domain\Models\ValueObjects\Products\NutritionalValues;
use Domain\Models\ValueObjects\Products\NutritionnalUnit;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
/**
* @implements CastsAttributes<NutritionalValues|null, NutritionalValues|array<string, mixed>|string|null>
*/
class NutritionalValuesCast implements CastsAttributes
{
/**
* @param array<string, mixed> $attributes
*/
public function get(Model $model, string $key, mixed $value, array $attributes): ?NutritionalValues
{
if ($value === null || $value === '' || $value === '[]') {
return null;
}
if (! is_string($value)) {
throw new \InvalidArgumentException('The given value is not a string.');
}
$data = json_decode($value, true);
if (! is_array($data)) {
throw new \InvalidArgumentException('The given value is not a valid JSON array.');
}
if ($data === []) {
return null;
}
return $this->fromArray($data);
}
/**
* @param array<string, mixed> $attributes
* @return array<string, string>
*/
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
if ($value === null || $value === [] || $value === '' || $value === '[]') {
return [$key => '[]'];
}
if (is_string($value)) {
$value = json_decode($value, true);
}
if (is_array($value)) {
$value = $this->fromArray($value);
}
if ($value === null) {
return [$key => '[]'];
}
if (! $value instanceof NutritionalValues) {
throw new \InvalidArgumentException('The given value is not a NutritionalValues instance.');
}
return [$key => (string) json_encode($value->toArray(), JSON_THROW_ON_ERROR)];
}
/**
* @param array<array-key, mixed> $data
*/
private function fromArray(array $data): ?NutritionalValues
{
if ($data === []) {
return null;
}
$getProperty = function (string $key) use ($data): ?NutritionalValue {
$item = Arr::get($data, $key);
return is_array($item) ? $this->toNutritionalValue($item) : null;
};
$refWeightG = $getProperty('refWeightG');
if ($refWeightG === null) {
return null;
}
return new NutritionalValues(
refWeightG: $refWeightG,
kj: $getProperty('kj'),
kcal: $getProperty('kcal'),
fatG: $getProperty('fatG'),
satFatG: $getProperty('satFatG'),
carbG: $getProperty('carbG'),
sugarG: $getProperty('sugarG'),
fiberG: $getProperty('fiberG'),
proteinG: $getProperty('proteinG'),
saltG: $getProperty('saltG'),
);
}
/**
* @param array<array-key, mixed> $data
*/
private function toNutritionalValue(array $data): ?NutritionalValue
{
$rawItemVal = Arr::get($data, 'value');
$rawItemUnit = Arr::get($data, 'unit');
if (! is_string($rawItemVal) && ! is_numeric($rawItemVal)) {
return null;
}
if (! is_string($rawItemUnit) && ! $rawItemUnit instanceof NutritionnalUnit) {
return null;
}
$value = mb_trim((string) $rawItemVal);
$unit = $rawItemUnit instanceof NutritionnalUnit
? $rawItemUnit
: NutritionnalUnit::tryFrom((string) $rawItemUnit);
if ($value === '' || $unit === null) {
return null;
}
return new NutritionalValue($value, $unit);
}
}

View file

@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Contracts;
/**
* @template TInput of \App\Contracts\ActionInput
* @template TResult
*/
interface Action
{
/**
* @param TInput $input
* @return TResult
*/
public function __invoke(ActionInput $input);
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Contracts;
interface ActionInput
{
}

View file

@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Contracts;
final class ActionInputEmpty implements ActionInput
{
public static function create(): self
{
return new self();
}
}

View file

@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Contracts\Directories;
use App\Contracts\Directory;
use App\Dto\CategoryProductsDto;
use Domain\Models\Entities\Product;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Models\ValueObjects\Entities\RecipeId;
use Domain\Models\ValueObjects\Pagination;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Illuminate\Pagination\LengthAwarePaginator;
/** @extends Directory<int, \Domain\Models\Entities\Product> */
interface ProductDirectory extends Directory
{
public function find(ProductId $id): ?Product;
public function findBySlug(string $slug): ?Product;
/**
* @return \Domain\Models\ValueObjects\Products\ProductSummary[]
*/
public function getAllProductsSummaries(): array;
/**
* @return LengthAwarePaginator<array-key,Product>
*/
public function getPaginatedProducts(
Pagination $pagination,
string $search = '',
): LengthAwarePaginator;
/**
* @param \Domain\Models\ValueObjects\Products\BreedingMethod[] $breedingMethods
* @param \Domain\Models\ValueObjects\Products\Technology[] $technologies
* @param \Domain\Models\ValueObjects\Products\Business[] $businesses
*/
public function getCategoryProducts(
ProductCategory $category,
array $breedingMethods = [],
array $technologies = [],
array $businesses = [],
?bool $isEgalim = null,
string $search = '',
int $limitPerCategory = 6
): ?CategoryProductsDto;
/** @return \Domain\Models\Entities\Product[] */
public function getRelatedProducts(RecipeId $recipeId, int $limit = 4): array;
}

View file

@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Contracts\Directories;
use App\Contracts\Directory;
use Domain\Models\Entities\Recipe;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Models\ValueObjects\Entities\RecipeId;
use Domain\Models\ValueObjects\Pagination;
use Illuminate\Pagination\LengthAwarePaginator;
/** @extends Directory<int, \Domain\Models\Entities\Recipe> */
interface RecipeDirectory extends Directory
{
public function find(RecipeId $id): ?Recipe;
public function findBySlug(string $slug): ?Recipe;
/**
* @param \Domain\Models\ValueObjects\Products\ProductCategory[] $productRanges
* @param \Domain\Models\ValueObjects\Recipes\ConsumptionTime[] $consumptionTimes
* @param \Domain\Models\ValueObjects\Recipes\Inspiration[] $inspirations
* @param \Domain\Models\ValueObjects\Recipes\Season[] $seasons
* @return LengthAwarePaginator<array-key,Recipe>
*/
public function getPaginatedRecipes(
Pagination $pagination,
array $productRanges = [],
array $consumptionTimes = [],
array $inspirations = [],
array $seasons = [],
string $search = ''
): LengthAwarePaginator;
/** @return \Domain\Models\Entities\Recipe[] */
public function getRelatedRecipes(ProductId $productId, int $limit = 4): array;
}

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Contracts;
/**
* @template TEntity
* @template TId
*
* @method TEntity|null find(TId $id)
*/
interface Directory
{
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Contracts;
interface Executor
{
/**
* @template TInputAction of \App\Contracts\ActionInput
* @template TInputQuery of \App\Contracts\QueryInput
* @template TResult
*
* @param \App\Contracts\Query<TInputQuery,TResult>|\App\Contracts\Action<TInputAction,TResult> $executable
* @param TInputAction|TInputQuery $input
* @return TResult
*/
public function execute(Action | Query $executable, ActionInput | QueryInput $input): mixed;
}

View file

@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Contracts;
/**
* @template TInput of \App\Contracts\QueryInput
* @template TResult
*/
interface Query
{
/**
* @param TInput $input
* @return TResult
*/
public function __invoke(QueryInput $input);
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Contracts;
interface QueryInput
{
}

View file

@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Contracts;
final class QueryInputEmpty implements QueryInput
{
public static function create(): self
{
return new self();
}
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Contracts;
/**
* @template TModel of \Illuminate\Database\Eloquent\Model
* @template TId of \Ramsey\Uuid\UuidInterface|int
*
* @method TModel|null find(TId $id)
* @method void persist(TModel $model)
* @method void delete(TModel $model)
*/
interface Repository
{
}

View file

@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\Create;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Product;
use Domain\Models\ValueObjects\Entities\ProductId;
use Ramsey\Uuid\Uuid;
use Webmozart\Assert\Assert;
/**
* phpcs:disable Generic.Files.LineLength.TooLong
*
* @implements \App\Contracts\Action<\App\Cqs\Actions\Products\Create\CreateProductInput, \Domain\Models\ValueObjects\Entities\ProductId|\App\Cqs\Actions\Products\Create\FailedToCreateProduct>
* phpcs:enable
*/
final class CreateProduct implements Action
{
/**
* @param \App\Cqs\Actions\Products\Create\CreateProductInput $input
*/
public function __invoke(ActionInput $input): ProductId | FailedToCreateProduct
{
Assert::isInstanceOf($input, CreateProductInput::class);
$product = new Product([
'seo_title' => $input->seoTitle,
'seo_description' => $input->seoDescription,
'name' => $input->name,
'picture' => $input->picture,
'picture_alt' => $input->pictureAlt,
'picture_title' => $input->pictureTitle,
'category' => $input->category,
'technology' => $input->technology,
'is_egalim' => $input->isEgalim,
'is_published' => $input->isPublished,
'breeding_methods' => $input->breedingMethods,
'businesses' => $input->businesses,
'formats' => $input->formats,
'description' => $input->description,
'details_and_advantages' => $input->detailsAndAdvantages,
'handling_advices' => $input->handlingAdvices,
'ingredients' => $input->ingredients,
'conservation_before_opening' => $input->conservationBeforeOpening,
'conservation_after_opening' => $input->conservationAfterOpening,
'nutritional_values' => $input->nutritionalValues,
'pdf' => $input->pdf,
]);
if (! $product->save()) {
return new FailedToCreateProduct();
}
return new ProductId(Uuid::fromString($product->id));
}
}

View file

@ -1,77 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\Create;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Products\BreedingMethod;
use Domain\Models\ValueObjects\Products\Business;
use Domain\Models\ValueObjects\Products\NutritionnalUnit;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Domain\Models\ValueObjects\Products\ProductFormat;
use Domain\Models\ValueObjects\Products\Technology;
use Uwil\Storage\Lib\ValueObjects\StorageFilePath;
use Webmozart\Assert\Assert;
final readonly class CreateProductInput implements ActionInput
{
/**
* @param BreedingMethod[] $breedingMethods
* @param Business[] $businesses
* @param ProductFormat[] $formats
* @param array<string, array{unit: NutritionnalUnit, value: string}> $nutritionalValues
*/
public function __construct(
public string $name,
public string $seoTitle,
public string $seoDescription,
public ProductCategory $category,
public Technology $technology,
public bool $isEgalim,
public bool $isPublished,
public array $breedingMethods,
public array $businesses,
public array $formats,
public string $description,
public string $detailsAndAdvantages,
public string $handlingAdvices,
public ?string $ingredients,
public string $conservationBeforeOpening,
public ?string $conservationAfterOpening,
public ?StorageFilePath $picture,
public string $pictureAlt,
public string $pictureTitle,
public ?StorageFilePath $pdf,
public array $nutritionalValues,
) {
Assert::notEmpty($this->name);
Assert::notEmpty($this->seoTitle);
Assert::notEmpty($this->seoDescription);
Assert::notEmpty($this->description);
Assert::notEmpty($this->detailsAndAdvantages);
Assert::notEmpty($this->handlingAdvices);
Assert::notEmpty($this->conservationBeforeOpening);
Assert::notEmpty($this->pictureAlt);
Assert::notEmpty($this->pictureTitle);
Assert::allIsAOf($this->breedingMethods, BreedingMethod::class);
Assert::allIsAOf($this->businesses, Business::class);
Assert::allIsAOf($this->formats, ProductFormat::class);
Assert::notEmpty($this->breedingMethods);
Assert::notEmpty($this->businesses);
Assert::notEmpty($this->formats);
when($this->ingredients !== null, fn () => Assert::notEmpty($this->ingredients));
when($this->conservationAfterOpening !== null, fn () => Assert::notEmpty($this->conservationAfterOpening));
foreach ($this->nutritionalValues as $value) {
Assert::isArray($value);
Assert::keyExists($value, 'unit');
Assert::keyExists($value, 'value');
Assert::isInstanceOf($value['unit'], NutritionnalUnit::class);
Assert::string($value['value']);
}
}
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\Create;
final readonly class FailedToCreateProduct
{
}

View file

@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\Delete;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Product;
use Domain\Models\ValueObjects\Entities\ProductId;
use Ramsey\Uuid\Uuid;
use Webmozart\Assert\Assert;
/**
* phpcs:disable Generic.Files.LineLength.TooLong
*
* @implements \App\Contracts\Action<\App\Cqs\Actions\Products\Delete\DeleteProductInput, \Domain\Models\ValueObjects\Entities\ProductId|\App\Cqs\Actions\Products\Delete\FailedToDeleteProduct>
* phpcs:enable
*/
final class DeleteProduct implements Action
{
/**
* @param \App\Cqs\Actions\Products\Delete\DeleteProductInput $input
*/
public function __invoke(ActionInput $input): ProductId | FailedToDeleteProduct
{
Assert::isInstanceOf($input, DeleteProductInput::class);
$product = Product::query()->findOrFail((string) $input->id->value);
if (! $product->delete()) {
return new FailedToDeleteProduct();
}
return new ProductId(Uuid::fromString($product->id));
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\Delete;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Entities\ProductId;
final readonly class DeleteProductInput implements ActionInput
{
public function __construct(
public ProductId $id,
) {
}
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\Delete;
final readonly class FailedToDeleteProduct
{
}

View file

@ -1,112 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\Duplicate;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Lib\PublicS3Disk;
use App\Models\Product;
use Domain\Models\ValueObjects\Entities\ProductId;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Ramsey\Uuid\Uuid;
use Uwil\Storage\Lib\ValueObjects\StorageFilePath;
use Webmozart\Assert\Assert;
/**
* phpcs:disable Generic.Files.LineLength.TooLong
*
* @implements \App\Contracts\Action<\App\Cqs\Actions\Products\Duplicate\DuplicateProductInput, \Domain\Models\ValueObjects\Entities\ProductId|\App\Cqs\Actions\Products\Duplicate\FailedToDuplicateProduct>
* phpcs:enable
*/
final class DuplicateProduct implements Action
{
public function __construct(
private readonly PublicS3Disk $s3Disk,
) {
}
/**
* @param \App\Cqs\Actions\Products\Duplicate\DuplicateProductInput $input
*/
public function __invoke(ActionInput $input): ProductId | FailedToDuplicateProduct
{
Assert::isInstanceOf($input, DuplicateProductInput::class);
$original = Product::withTrashed()->findOrFail((string) $input->productId->value);
$clone = $original->replicate();
$clone->name = $this->generateUniqueName($original->name);
$clone->slug = Str::slug($clone->name);
$duplicatedPicture = $this->duplicateFile($original->picture);
if ($duplicatedPicture !== null) {
$clone->picture = $duplicatedPicture;
}
$clone->pdf = $this->duplicateFile($original->pdf);
$clone->is_published = false;
if (! $clone->save()) {
return new FailedToDuplicateProduct();
}
return new ProductId(Uuid::fromString($clone->id));
}
private function generateUniqueName(string $originalName): string
{
$rootName = $originalName;
if (preg_match('/^(.*?)(?: \(Copie(?: (\d+))?\))?$/', $originalName, $matches)) {
$rootName = $matches[1];
}
$baseName = "{$rootName} (Copie)";
if (
! Product::withTrashed()
->where('name', $baseName)
->orWhere('slug', Str::slug($baseName))
->exists()
) {
return $baseName;
}
$i = 2;
while (
Product::withTrashed()
->where('name', "{$rootName} (Copie {$i})")
->orWhere('slug', Str::slug("{$rootName} (Copie {$i})"))
->exists()
) {
$i++;
}
return "{$rootName} (Copie {$i})";
}
private function duplicateFile(?StorageFilePath $filePath): ?StorageFilePath
{
if ($filePath === null) {
return null;
}
$sourcePath = $filePath->value;
$disk = Storage::disk($this->s3Disk->disk->value);
if (! $disk->exists($sourcePath)) {
return null;
}
$extension = pathinfo($sourcePath, PATHINFO_EXTENSION);
$dirname = pathinfo($sourcePath, PATHINFO_DIRNAME);
$newFilename = Str::uuid()->toString() . ($extension !== '' ? '.' . $extension : '');
$destinationPath = ($dirname !== '.' && $dirname !== '') ? "{$dirname}/{$newFilename}" : $newFilename;
if (! $disk->copy($sourcePath, $destinationPath)) {
return null;
}
return new StorageFilePath($destinationPath);
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\Duplicate;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Entities\ProductId;
final readonly class DuplicateProductInput implements ActionInput
{
public function __construct(
public ProductId $productId,
) {
}
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\Duplicate;
final readonly class FailedToDuplicateProduct
{
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\ForceDelete;
final readonly class FailedToForceDeleteProduct
{
}

View file

@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\ForceDelete;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Lib\PublicS3Disk;
use App\Models\Product;
use Domain\Models\ValueObjects\Entities\ProductId;
use Ramsey\Uuid\Uuid;
use Uwil\Storage\Contracts\StorageService;
use Webmozart\Assert\Assert;
/**
* phpcs:disable Generic.Files.LineLength.TooLong
*
* @implements \App\Contracts\Action<\App\Cqs\Actions\Products\ForceDelete\ForceDeleteProductInput, \Domain\Models\ValueObjects\Entities\ProductId|\App\Cqs\Actions\Products\ForceDelete\FailedToForceDeleteProduct>
* phpcs:enable
*/
final class ForceDeleteProduct implements Action
{
public function __construct(
private readonly StorageService $storageService,
private readonly PublicS3Disk $s3Disk,
) {
}
/**
* @param \App\Cqs\Actions\Products\ForceDelete\ForceDeleteProductInput $input
*/
public function __invoke(ActionInput $input): ProductId | FailedToForceDeleteProduct
{
Assert::isInstanceOf($input, ForceDeleteProductInput::class);
$product = Product::withTrashed()->findOrFail((string) $input->id->value);
$picture = $product->picture;
$pdf = $product->pdf;
if (! $product->forceDelete()) {
return new FailedToForceDeleteProduct();
}
if ($picture !== null) {
$this->storageService->removeFromDisk($picture, $this->s3Disk->disk);
}
if ($pdf !== null) {
$this->storageService->removeFromDisk($pdf, $this->s3Disk->disk);
}
return new ProductId(Uuid::fromString($product->id));
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\ForceDelete;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Entities\ProductId;
final readonly class ForceDeleteProductInput implements ActionInput
{
public function __construct(
public ProductId $id,
) {
}
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\Restore;
final readonly class FailedToRestoreProduct
{
}

View file

@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\Restore;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Product;
use Domain\Models\ValueObjects\Entities\ProductId;
use Ramsey\Uuid\Uuid;
use Webmozart\Assert\Assert;
/**
* phpcs:disable Generic.Files.LineLength.TooLong
*
* @implements \App\Contracts\Action<\App\Cqs\Actions\Products\Restore\RestoreProductInput, \Domain\Models\ValueObjects\Entities\ProductId|\App\Cqs\Actions\Products\Restore\FailedToRestoreProduct>
* phpcs:enable
*/
final class RestoreProduct implements Action
{
/**
* @param \App\Cqs\Actions\Products\Restore\RestoreProductInput $input
*/
public function __invoke(ActionInput $input): ProductId | FailedToRestoreProduct
{
Assert::isInstanceOf($input, RestoreProductInput::class);
$product = Product::withTrashed()->findOrFail((string) $input->id->value);
if (! $product->restore()) {
return new FailedToRestoreProduct();
}
return new ProductId(Uuid::fromString($product->id));
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\Restore;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Entities\ProductId;
final readonly class RestoreProductInput implements ActionInput
{
public function __construct(
public ProductId $id,
) {
}
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\Update;
final readonly class FailedToUpdateProduct
{
}

View file

@ -1,84 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\Update;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Dto\ProductDto;
use App\Lib\PublicS3Disk;
use App\Models\Product;
use Uwil\Storage\Contracts\StorageService;
use Webmozart\Assert\Assert;
/**
* phpcs:disable Generic.Files.LineLength.TooLong
*
* @implements \App\Contracts\Action<\App\Cqs\Actions\Products\Update\UpdateProductInput, \App\Dto\ProductDto|\App\Cqs\Actions\Products\Update\FailedToUpdateProduct>
* phpcs:enable
*/
final class UpdateProduct implements Action
{
public function __construct(
private readonly StorageService $storageService,
private readonly PublicS3Disk $s3Disk,
) {
}
/**
* @param \App\Cqs\Actions\Products\Update\UpdateProductInput $input
*/
public function __invoke(ActionInput $input): ProductDto | FailedToUpdateProduct
{
Assert::isInstanceOf($input, UpdateProductInput::class);
$product = Product::query()->findOrFail((string) $input->id->value);
$oldPicture = $product->picture;
$oldPdf = $product->pdf;
$product->fill([
'seo_title' => $input->seoTitle,
'seo_description' => $input->seoDescription,
'name' => $input->name,
'picture_alt' => $input->pictureAlt,
'picture_title' => $input->pictureTitle,
'category' => $input->category,
'technology' => $input->technology,
'is_egalim' => $input->isEgalim,
'is_published' => $input->isPublished,
'breeding_methods' => $input->breedingMethods,
'businesses' => $input->businesses,
'formats' => $input->formats,
'description' => $input->description,
'details_and_advantages' => $input->detailsAndAdvantages,
'handling_advices' => $input->handlingAdvices,
'ingredients' => $input->ingredients,
'conservation_before_opening' => $input->conservationBeforeOpening,
'conservation_after_opening' => $input->conservationAfterOpening,
'nutritional_values' => $input->nutritionalValues,
]);
if ($input->picture !== null) {
$product->picture = $input->picture;
}
$product->pdf = $input->pdf;
if (! $product->save()) {
return new FailedToUpdateProduct();
}
// Clean up replaced or removed S3 files
if ($input->picture !== null && $oldPicture !== null && $oldPicture->value !== $input->picture->value) {
$this->storageService->removeFromDisk($oldPicture, $this->s3Disk->disk);
}
if ($oldPdf !== null && ($input->pdf === null || $oldPdf->value !== $input->pdf->value)) {
$this->storageService->removeFromDisk($oldPdf, $this->s3Disk->disk);
}
return ProductDto::fromModel($product, $this->s3Disk);
}
}

View file

@ -1,79 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Products\Update;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Models\ValueObjects\Products\BreedingMethod;
use Domain\Models\ValueObjects\Products\Business;
use Domain\Models\ValueObjects\Products\NutritionnalUnit;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Domain\Models\ValueObjects\Products\ProductFormat;
use Domain\Models\ValueObjects\Products\Technology;
use Uwil\Storage\Lib\ValueObjects\StorageFilePath;
use Webmozart\Assert\Assert;
final readonly class UpdateProductInput implements ActionInput
{
/**
* @param BreedingMethod[] $breedingMethods
* @param Business[] $businesses
* @param ProductFormat[] $formats
* @param array<string, array{unit: NutritionnalUnit, value: string}> $nutritionalValues
*/
public function __construct(
public ProductId $id,
public string $name,
public string $seoTitle,
public string $seoDescription,
public ProductCategory $category,
public Technology $technology,
public bool $isEgalim,
public bool $isPublished,
public array $breedingMethods,
public array $businesses,
public array $formats,
public string $description,
public string $detailsAndAdvantages,
public string $handlingAdvices,
public ?string $ingredients,
public string $conservationBeforeOpening,
public ?string $conservationAfterOpening,
public ?StorageFilePath $picture,
public string $pictureAlt,
public string $pictureTitle,
public ?StorageFilePath $pdf,
public array $nutritionalValues,
) {
Assert::notEmpty($this->name);
Assert::notEmpty($this->seoTitle);
Assert::notEmpty($this->seoDescription);
Assert::notEmpty($this->description);
Assert::notEmpty($this->detailsAndAdvantages);
Assert::notEmpty($this->handlingAdvices);
Assert::notEmpty($this->conservationBeforeOpening);
Assert::notEmpty($this->pictureAlt);
Assert::notEmpty($this->pictureTitle);
Assert::allIsAOf($this->breedingMethods, BreedingMethod::class);
Assert::allIsAOf($this->businesses, Business::class);
Assert::allIsAOf($this->formats, ProductFormat::class);
Assert::notEmpty($this->breedingMethods);
Assert::notEmpty($this->businesses);
Assert::notEmpty($this->formats);
when($this->ingredients !== null, fn () => Assert::notEmpty($this->ingredients));
when($this->conservationAfterOpening !== null, fn () => Assert::notEmpty($this->conservationAfterOpening));
foreach ($this->nutritionalValues as $value) {
Assert::isArray($value);
Assert::keyExists($value, 'unit');
Assert::keyExists($value, 'value');
Assert::isInstanceOf($value['unit'], NutritionnalUnit::class);
Assert::string($value['value']);
}
}
}

View file

@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\Create;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Recipe;
use Domain\Models\ValueObjects\Entities\RecipeId;
use Ramsey\Uuid\Uuid;
use Webmozart\Assert\Assert;
/**
* phpcs:disable Generic.Files.LineLength.TooLong
*
* @implements \App\Contracts\Action<\App\Cqs\Actions\Recipes\Create\CreateRecipeInput, \Domain\Models\ValueObjects\Entities\RecipeId|\App\Cqs\Actions\Recipes\Create\FailedToCreateRecipe>
* phpcs:enable
*/
final class CreateRecipe implements Action
{
/**
* @param \App\Cqs\Actions\Recipes\Create\CreateRecipeInput $input
*/
public function __invoke(ActionInput $input): RecipeId | FailedToCreateRecipe
{
Assert::isInstanceOf($input, CreateRecipeInput::class);
$recipe = new Recipe([
'seo_title' => $input->seoTitle,
'seo_description' => $input->seoDescription,
'name' => $input->name,
'is_published' => $input->isPublished,
'picture' => $input->picture,
'picture_alt' => $input->pictureAlt,
'picture_title' => $input->pictureTitle,
'product_category' => $input->productCategory,
'consomption_time' => $input->consomptionTime,
'inspiration' => $input->inspiration,
'season' => $input->season,
'description' => $input->description,
'details' => $input->details,
'ingredients_list' => $input->ingredientsList,
'preparation_steps' => $input->preparationSteps,
'tips_and_advices' => $input->tipsAndAdvices,
]);
if (! $recipe->save()) {
return new FailedToCreateRecipe();
}
$recipe->products()->sync($input->products);
return new RecipeId(Uuid::fromString($recipe->id));
}
}

View file

@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\Create;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Domain\Models\ValueObjects\Recipes\ConsumptionTime;
use Domain\Models\ValueObjects\Recipes\Inspiration;
use Domain\Models\ValueObjects\Recipes\Season;
use Uwil\Storage\Lib\ValueObjects\StorageFilePath;
use Webmozart\Assert\Assert;
final readonly class CreateRecipeInput implements ActionInput
{
/**
* @param string[] $products
*/
public function __construct(
public string $name,
public string $seoTitle,
public string $seoDescription,
public bool $isPublished,
public ProductCategory $productCategory,
public ConsumptionTime $consomptionTime,
public Inspiration $inspiration,
public Season $season,
public ?string $description,
public ?string $details,
public string $ingredientsList,
public string $preparationSteps,
public ?string $tipsAndAdvices,
public ?StorageFilePath $picture,
public string $pictureAlt,
public string $pictureTitle,
public array $products = [],
) {
Assert::notEmpty($this->name);
Assert::notEmpty($this->seoTitle);
Assert::notEmpty($this->seoDescription);
Assert::notEmpty($this->ingredientsList);
Assert::notEmpty($this->preparationSteps);
Assert::notEmpty($this->pictureAlt);
Assert::notEmpty($this->pictureTitle);
when($this->description !== null, fn () => Assert::notEmpty($this->description));
when($this->details !== null, fn () => Assert::notEmpty($this->details));
when($this->tipsAndAdvices !== null, fn () => Assert::notEmpty($this->tipsAndAdvices));
Assert::allStringNotEmpty($this->products);
}
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\Create;
final readonly class FailedToCreateRecipe
{
}

View file

@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\Delete;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Recipe;
use Domain\Models\ValueObjects\Entities\RecipeId;
use Ramsey\Uuid\Uuid;
use Webmozart\Assert\Assert;
/**
* phpcs:disable Generic.Files.LineLength.TooLong
*
* @implements \App\Contracts\Action<\App\Cqs\Actions\Recipes\Delete\DeleteRecipeInput, \Domain\Models\ValueObjects\Entities\RecipeId|\App\Cqs\Actions\Recipes\Delete\FailedToDeleteRecipe>
* phpcs:enable
*/
final class DeleteRecipe implements Action
{
/**
* @param \App\Cqs\Actions\Recipes\Delete\DeleteRecipeInput $input
*/
public function __invoke(ActionInput $input): RecipeId | FailedToDeleteRecipe
{
Assert::isInstanceOf($input, DeleteRecipeInput::class);
$recipe = Recipe::query()->findOrFail((string) $input->id->value);
if (! $recipe->delete()) {
return new FailedToDeleteRecipe();
}
return new RecipeId(Uuid::fromString($recipe->id));
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\Delete;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Entities\RecipeId;
final readonly class DeleteRecipeInput implements ActionInput
{
public function __construct(
public RecipeId $id,
) {
}
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\Delete;
final readonly class FailedToDeleteRecipe
{
}

View file

@ -1,116 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\Duplicate;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Lib\PublicS3Disk;
use App\Models\Recipe;
use Domain\Models\ValueObjects\Entities\RecipeId;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Ramsey\Uuid\Uuid;
use Uwil\Storage\Lib\ValueObjects\StorageFilePath;
use Webmozart\Assert\Assert;
/**
* phpcs:disable Generic.Files.LineLength.TooLong
*
* @implements \App\Contracts\Action<\App\Cqs\Actions\Recipes\Duplicate\DuplicateRecipeInput, \Domain\Models\ValueObjects\Entities\RecipeId|\App\Cqs\Actions\Recipes\Duplicate\FailedToDuplicateRecipe>
* phpcs:enable
*/
final class DuplicateRecipe implements Action
{
public function __construct(
private readonly PublicS3Disk $s3Disk,
) {
}
/**
* @param \App\Cqs\Actions\Recipes\Duplicate\DuplicateRecipeInput $input
*/
public function __invoke(ActionInput $input): RecipeId | FailedToDuplicateRecipe
{
Assert::isInstanceOf($input, DuplicateRecipeInput::class);
$original = Recipe::withTrashed()->with('products')->findOrFail((string) $input->recipeId->value);
$clone = $original->replicate();
$clone->name = $this->generateUniqueName($original->name);
$clone->slug = Str::slug($clone->name);
$duplicatedPicture = $this->duplicateFile($original->picture);
if ($duplicatedPicture !== null) {
$clone->picture = $duplicatedPicture;
}
$clone->is_published = false;
if (! $clone->save()) {
return new FailedToDuplicateRecipe();
}
$productIds = $original->products->pluck('id')->all();
if (! empty($productIds)) {
$clone->products()->sync($productIds);
}
return new RecipeId(Uuid::fromString($clone->id));
}
private function generateUniqueName(string $originalName): string
{
$rootName = $originalName;
if (preg_match('/^(.*?)(?: \(Copie(?: (\d+))?\))?$/', $originalName, $matches)) {
$rootName = $matches[1];
}
$baseName = "{$rootName} (Copie)";
if (
! Recipe::withTrashed()
->where('name', $baseName)
->orWhere('slug', Str::slug($baseName))
->exists()
) {
return $baseName;
}
$i = 2;
while (
Recipe::withTrashed()
->where('name', "{$rootName} (Copie {$i})")
->orWhere('slug', Str::slug("{$rootName} (Copie {$i})"))->exists()
) {
$i++;
}
return "{$rootName} (Copie {$i})";
}
private function duplicateFile(?StorageFilePath $filePath): ?StorageFilePath
{
if ($filePath === null) {
return null;
}
$sourcePath = $filePath->value;
$disk = Storage::disk($this->s3Disk->disk->value);
if (! $disk->exists($sourcePath)) {
return null;
}
$extension = pathinfo($sourcePath, PATHINFO_EXTENSION);
$dirname = pathinfo($sourcePath, PATHINFO_DIRNAME);
$newFilename = Str::uuid()->toString() . ($extension !== '' ? '.' . $extension : '');
$destinationPath = ($dirname !== '.' && $dirname !== '') ? "{$dirname}/{$newFilename}" : $newFilename;
if (! $disk->copy($sourcePath, $destinationPath)) {
return null;
}
return new StorageFilePath($destinationPath);
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\Duplicate;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Entities\RecipeId;
final readonly class DuplicateRecipeInput implements ActionInput
{
public function __construct(
public RecipeId $recipeId,
) {
}
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\Duplicate;
final readonly class FailedToDuplicateRecipe
{
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\ForceDelete;
final readonly class FailedToForceDeleteRecipe
{
}

View file

@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\ForceDelete;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Lib\PublicS3Disk;
use App\Models\Recipe;
use Domain\Models\ValueObjects\Entities\RecipeId;
use Ramsey\Uuid\Uuid;
use Uwil\Storage\Contracts\StorageService;
use Webmozart\Assert\Assert;
/**
* phpcs:disable Generic.Files.LineLength.TooLong
*
* @implements \App\Contracts\Action<\App\Cqs\Actions\Recipes\ForceDelete\ForceDeleteRecipeInput, \Domain\Models\ValueObjects\Entities\RecipeId|\App\Cqs\Actions\Recipes\ForceDelete\FailedToForceDeleteRecipe>
* phpcs:enable
*/
final class ForceDeleteRecipe implements Action
{
public function __construct(
private readonly StorageService $storageService,
private readonly PublicS3Disk $s3Disk,
) {
}
/**
* @param \App\Cqs\Actions\Recipes\ForceDelete\ForceDeleteRecipeInput $input
*/
public function __invoke(ActionInput $input): RecipeId | FailedToForceDeleteRecipe
{
Assert::isInstanceOf($input, ForceDeleteRecipeInput::class);
$recipe = Recipe::withTrashed()->findOrFail((string) $input->id->value);
$picture = $recipe->picture;
if (! $recipe->forceDelete()) {
return new FailedToForceDeleteRecipe();
}
if ($picture !== null) {
$this->storageService->removeFromDisk($picture, $this->s3Disk->disk);
}
return new RecipeId(Uuid::fromString($recipe->id));
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\ForceDelete;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Entities\RecipeId;
final readonly class ForceDeleteRecipeInput implements ActionInput
{
public function __construct(
public RecipeId $id,
) {
}
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\Restore;
final readonly class FailedToRestoreRecipe
{
}

View file

@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\Restore;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Recipe;
use Domain\Models\ValueObjects\Entities\RecipeId;
use Ramsey\Uuid\Uuid;
use Webmozart\Assert\Assert;
/**
* phpcs:disable Generic.Files.LineLength.TooLong
*
* @implements \App\Contracts\Action<\App\Cqs\Actions\Recipes\Restore\RestoreRecipeInput, \Domain\Models\ValueObjects\Entities\RecipeId|\App\Cqs\Actions\Recipes\Restore\FailedToRestoreRecipe>
* phpcs:enable
*/
final class RestoreRecipe implements Action
{
/**
* @param \App\Cqs\Actions\Recipes\Restore\RestoreRecipeInput $input
*/
public function __invoke(ActionInput $input): RecipeId | FailedToRestoreRecipe
{
Assert::isInstanceOf($input, RestoreRecipeInput::class);
$recipe = Recipe::withTrashed()->findOrFail((string) $input->id->value);
if (! $recipe->restore()) {
return new FailedToRestoreRecipe();
}
return new RecipeId(Uuid::fromString($recipe->id));
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\Restore;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Entities\RecipeId;
final readonly class RestoreRecipeInput implements ActionInput
{
public function __construct(
public RecipeId $id,
) {
}
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\Update;
final readonly class FailedToUpdateRecipe
{
}

View file

@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\Update;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Dto\RecipeDto;
use App\Lib\PublicS3Disk;
use App\Models\Recipe;
use Uwil\Storage\Contracts\StorageService;
use Webmozart\Assert\Assert;
/**
* phpcs:disable Generic.Files.LineLength.TooLong
*
* @implements \App\Contracts\Action<\App\Cqs\Actions\Recipes\Update\UpdateRecipeInput, \App\Dto\RecipeDto|\App\Cqs\Actions\Recipes\Update\FailedToUpdateRecipe>
* phpcs:enable
*/
final class UpdateRecipe implements Action
{
public function __construct(
private readonly StorageService $storageService,
private readonly PublicS3Disk $s3Disk,
) {
}
/**
* @param \App\Cqs\Actions\Recipes\Update\UpdateRecipeInput $input
*/
public function __invoke(ActionInput $input): RecipeDto | FailedToUpdateRecipe
{
Assert::isInstanceOf($input, UpdateRecipeInput::class);
$recipe = Recipe::query()->findOrFail((string) $input->id->value);
$oldPicture = $recipe->picture;
$recipe->fill([
'seo_title' => $input->seoTitle,
'seo_description' => $input->seoDescription,
'name' => $input->name,
'is_published' => $input->isPublished,
'picture_alt' => $input->pictureAlt,
'picture_title' => $input->pictureTitle,
'product_category' => $input->productCategory,
'consomption_time' => $input->consomptionTime,
'inspiration' => $input->inspiration,
'season' => $input->season,
'description' => $input->description,
'details' => $input->details,
'ingredients_list' => $input->ingredientsList,
'preparation_steps' => $input->preparationSteps,
'tips_and_advices' => $input->tipsAndAdvices,
]);
if ($input->picture !== null) {
$recipe->picture = $input->picture;
}
if (! $recipe->save()) {
return new FailedToUpdateRecipe();
}
$recipe->products()->sync($input->products);
// Clean up replaced S3 picture
if ($input->picture !== null && $oldPicture !== null && $oldPicture->value !== $input->picture->value) {
$this->storageService->removeFromDisk($oldPicture, $this->s3Disk->disk);
}
return RecipeDto::fromModel($recipe, $this->s3Disk);
}
}

View file

@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Actions\Recipes\Update;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Entities\RecipeId;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Domain\Models\ValueObjects\Recipes\ConsumptionTime;
use Domain\Models\ValueObjects\Recipes\Inspiration;
use Domain\Models\ValueObjects\Recipes\Season;
use Uwil\Storage\Lib\ValueObjects\StorageFilePath;
use Webmozart\Assert\Assert;
final readonly class UpdateRecipeInput implements ActionInput
{
/**
* @param string[] $products
*/
public function __construct(
public RecipeId $id,
public string $name,
public string $seoTitle,
public string $seoDescription,
public bool $isPublished,
public ProductCategory $productCategory,
public ConsumptionTime $consomptionTime,
public Inspiration $inspiration,
public Season $season,
public ?string $description,
public ?string $details,
public string $ingredientsList,
public string $preparationSteps,
public ?string $tipsAndAdvices,
public ?StorageFilePath $picture,
public string $pictureAlt,
public string $pictureTitle,
public array $products = [],
) {
Assert::notEmpty($this->name);
Assert::notEmpty($this->seoTitle);
Assert::notEmpty($this->seoDescription);
Assert::notEmpty($this->ingredientsList);
Assert::notEmpty($this->preparationSteps);
Assert::notEmpty($this->pictureAlt);
Assert::notEmpty($this->pictureTitle);
when($this->description !== null, fn () => Assert::notEmpty($this->description));
when($this->details !== null, fn () => Assert::notEmpty($this->details));
when($this->tipsAndAdvices !== null, fn () => Assert::notEmpty($this->tipsAndAdvices));
Assert::allStringNotEmpty($this->products);
}
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Products;
use App\Contracts\QueryInput;
use Ramsey\Uuid\UuidInterface;
final readonly class FindProductByIdInput implements QueryInput
{
public function __construct(
public UuidInterface $id,
public bool $includeTrashed = false
) {
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Products;
use App\Contracts\QueryInput;
final readonly class FindProductBySlugInput implements QueryInput
{
public function __construct(
public string $slug,
public bool $includeTrashed = false
) {
}
}

View file

@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Products;
use App\Contracts\Query;
use App\Contracts\QueryInput;
use App\Dto\ProductDto;
use App\Lib\PublicS3Disk;
use App\Models\Product;
use Webmozart\Assert\Assert;
/**
* @phpcs:disable Generic.Files.LineLength
*
* @implements \App\Contracts\Query<\App\Cqs\Queries\Products\FindProductByIdInput|\App\Cqs\Queries\Products\FindProductBySlugInput, \App\Dto\ProductDto|null>
*
* @phpcs:enable
*/
final readonly class FindProductQuery implements Query
{
public function __construct(
private PublicS3Disk $publicS3Disk,
) {
}
#[\Override]
public function __invoke(QueryInput $input): ?ProductDto
{
Assert::true(
$input instanceof FindProductByIdInput || $input instanceof FindProductBySlugInput,
'Input must be an instance of FindProductByIdInput or FindProductBySlugInput.'
);
$query = Product::query();
if ($input->includeTrashed) {
$query->withTrashed();
}
/** @var Product|null $product */
$product = match (true) {
$input instanceof FindProductByIdInput => $query->where('id', (string) $input->id)->first(),
$input instanceof FindProductBySlugInput => $query->where('slug', $input->slug)->first(),
};
if ($product === null) {
return null;
}
return ProductDto::fromModel($product, $this->publicS3Disk);
}
}

View file

@ -1,80 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Products;
use App\Contracts\Query;
use App\Contracts\QueryInput;
use App\Models\Product;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Domain\Models\ValueObjects\Products\Technology;
use Illuminate\Database\Eloquent\Builder;
use SortDirection;
use Webmozart\Assert\Assert;
/**
* phpcs:disable Generic.Files.LineLength
*
* @implements \App\Contracts\Query<\App\Cqs\Queries\Products\GetBoPaginatedProductsQueryInput, \App\Cqs\Queries\Products\GetBoPaginatedProductsQueryOutput>
* phpcs:enable
*/
final readonly class GetBoPaginatedProductsQuery implements Query
{
private const array SORTABLE_COLUMNS = [
'name',
'category',
'technology',
'is_egalim',
'is_published',
'created_at',
'updated_at',
];
#[\Override]
public function __invoke(QueryInput $input): GetBoPaginatedProductsQueryOutput
{
Assert::isAOf($input, GetBoPaginatedProductsQueryInput::class);
/** @var Builder<Product> $query */
$query = Product::query();
if ($input->isTrash) {
$query->onlyTrashed();
}
if ($input->search !== null && $input->search !== '') {
$words = array_filter(explode(' ', $input->search));
$query->where(function (Builder $q) use ($words, $input): void {
foreach ($words as $word) {
$q->where('name', 'like', "%{$word}%");
}
$q->orWhere('name', 'like', "%{$input->search}%");
});
}
$sortCol = in_array($input->sortCol, self::SORTABLE_COLUMNS, true)
? $input->sortCol
: 'name';
$direction = $input->sortDirection === SortDirection::Descending ? 'desc' : 'asc';
$query->orderBy($sortCol, $direction);
$paginated = $query->paginate(
perPage: $input->pagination->pageSize(),
page: $input->pagination->pageNumber(),
)->withQueryString();
$searchFields = \implode(', ', [
trans('validation.attributes.name'),
]);
return new GetBoPaginatedProductsQueryOutput(
paginatedProducts: $paginated,
search: $input->search,
searchFields: $searchFields,
categories: ProductCategory::toArray(),
technologies: Technology::toArray(),
);
}
}

View file

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Products;
use App\Contracts\QueryInput;
use Domain\Models\ValueObjects\Pagination;
use SortDirection;
final readonly class GetBoPaginatedProductsQueryInput implements QueryInput
{
public function __construct(
public Pagination $pagination,
public ?string $search = null,
public string $sortCol = 'name',
public SortDirection $sortDirection = SortDirection::Ascending,
public bool $isTrash = false,
) {
}
}

View file

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Products;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
final readonly class GetBoPaginatedProductsQueryOutput
{
/**
* @param LengthAwarePaginator<int,\App\Models\Product> $paginatedProducts
* @param array<string, mixed> $categories
* @param array<string, mixed> $technologies
*/
public function __construct(
public LengthAwarePaginator $paginatedProducts,
public ?string $search,
public string $searchFields,
public array $categories,
public array $technologies,
) {
}
}

View file

@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Products;
use App\Contracts\Directories\ProductDirectory;
use App\Contracts\Query;
use App\Contracts\QueryInput;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Query<\App\Cqs\Queries\Products\GetCategoriesProductsQueryInput, array<int, \App\Dto\CategoryProductsDto>>
*/
final readonly class GetCategoriesProductsQuery implements Query
{
public function __construct(
private ProductDirectory $productDirectory,
) {
}
/**
* @return array<int, \App\Dto\CategoryProductsDto>
*/
public function __invoke(QueryInput $input): array
{
Assert::isAOf($input, GetCategoriesProductsQueryInput::class);
$categories = $input->categories;
if (count($categories) === 0) {
$categories = ! $input->hasFilter ? [
ProductCategory::OMELETS_AND_DELI_AND_DESSERT_LINES,
ProductCategory::HARD_BOILED_EGGS,
] : ProductCategory::cases();
}
$categoriesProducts = [];
foreach ($categories as $category) {
$result = $this->productDirectory->getCategoryProducts(
category: $category,
breedingMethods: $input->breedingMethods,
technologies: $input->technologies,
businesses: $input->businesses,
isEgalim: $input->isEgalim,
search: $input->search,
limitPerCategory: -1
);
if ($result !== null) {
$categoriesProducts[] = $result;
}
}
return $categoriesProducts;
}
}

View file

@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Products;
use App\Contracts\QueryInput;
use Domain\Models\ValueObjects\Products\BreedingMethod;
use Domain\Models\ValueObjects\Products\Business;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Domain\Models\ValueObjects\Products\Technology;
use Webmozart\Assert\Assert;
final readonly class GetCategoriesProductsQueryInput implements QueryInput
{
/**
* @param ProductCategory[] $categories
* @param BreedingMethod[] $breedingMethods
* @param Technology[] $technologies
* @param Business[] $businesses
*/
public function __construct(
public array $categories = [],
public array $breedingMethods = [],
public array $technologies = [],
public array $businesses = [],
public ?bool $isEgalim = null,
public string $search = '',
public bool $hasFilter = false,
) {
Assert::allIsAOf($this->categories, ProductCategory::class);
Assert::allIsAOf($this->breedingMethods, BreedingMethod::class);
Assert::allIsAOf($this->technologies, Technology::class);
Assert::allIsAOf($this->businesses, Business::class);
}
}

View file

@ -1,51 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Products;
use App\Contracts\Directories\ProductDirectory;
use App\Contracts\Query;
use App\Contracts\QueryInput;
use App\Contracts\QueryInputEmpty;
use App\Dto\ProductSummaryDto;
use App\Lib\PublicS3Disk;
use Domain\Models\ValueObjects\Products\ProductSummary;
use Illuminate\Support\Collection;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Query<\App\Contracts\QueryInputEmpty, \Illuminate\Support\Collection<int, \App\Dto\ProductSummaryDto>>
*/
final readonly class GetProductsSummariesQuery implements Query
{
public function __construct(
private ProductDirectory $productDirectory,
private PublicS3Disk $publicS3Disk
) {
}
/**
* @return \Illuminate\Support\Collection<int, \App\Dto\ProductSummaryDto>
*/
public function __invoke(QueryInput $input): Collection
{
Assert::isAOf(
$input,
QueryInputEmpty::class,
);
return Collection::make($this->productDirectory->getAllProductsSummaries())
->map(fn (ProductSummary $product): ProductSummaryDto => $this->mapToDto($product));
}
private function mapToDto(ProductSummary $product): ProductSummaryDto
{
return new ProductSummaryDto(
id: $product->id,
name: $product->name,
pictureUrl: $product->picture ?
$this->publicS3Disk->publicUrlOf($product->picture) : null,
);
}
}

View file

@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Products;
use App\Contracts\Directories\ProductDirectory;
use App\Contracts\Query;
use App\Contracts\QueryInput;
use App\Dto\ProductDto;
use App\Lib\PublicS3Disk;
use Domain\Models\Entities\Product as DomainProduct;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Query<\App\Cqs\Queries\Products\GetRelatedProductsQueryInput, array<\App\Dto\ProductDto>>
*/
final readonly class GetRelatedProductsQuery implements Query
{
public function __construct(
private ProductDirectory $productDirectory,
private PublicS3Disk $publicS3Disk,
) {
}
/**
* @return array< ProductDto>
*/
public function __invoke(QueryInput $input): array
{
Assert::isAOf($input, GetRelatedProductsQueryInput::class);
$products = $this->productDirectory->getRelatedProducts($input->recipeId, $input->limit);
return array_map(
fn (DomainProduct $product): ProductDto => ProductDto::fromEntity(
$product,
$this->publicS3Disk
),
$products
);
}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Products;
use App\Contracts\QueryInput;
use Domain\Models\ValueObjects\Entities\RecipeId;
use Webmozart\Assert\Assert;
final readonly class GetRelatedProductsQueryInput implements QueryInput
{
public function __construct(
public RecipeId $recipeId,
public int $limit = 10,
) {
Assert::greaterThan($this->limit, 0);
}
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Recipes;
use App\Contracts\QueryInput;
use Ramsey\Uuid\UuidInterface;
final readonly class FindRecipeByIdInput implements QueryInput
{
public function __construct(
public UuidInterface $id,
public bool $includeTrashed = false
) {
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Recipes;
use App\Contracts\QueryInput;
final readonly class FindRecipeBySlugInput implements QueryInput
{
public function __construct(
public string $slug,
public bool $includeTrashed = false
) {
}
}

View file

@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Recipes;
use App\Contracts\Query;
use App\Contracts\QueryInput;
use App\Dto\RecipeDto;
use App\Lib\PublicS3Disk;
use App\Models\Recipe;
use Webmozart\Assert\Assert;
/**
* @phpcs:disable Generic.Files.LineLength
*
* @implements \App\Contracts\Query<\App\Cqs\Queries\Recipes\FindRecipeByIdInput|\App\Cqs\Queries\Recipes\FindRecipeBySlugInput, \App\Dto\RecipeDto|null>
*
* @phpcs:enable
*/
final readonly class FindRecipeQuery implements Query
{
public function __construct(
private PublicS3Disk $publicS3Disk,
) {
}
#[\Override]
public function __invoke(QueryInput $input): ?RecipeDto
{
Assert::true(
$input instanceof FindRecipeByIdInput || $input instanceof FindRecipeBySlugInput,
'Input must be an instance of FindRecipeByIdInput or FindRecipeBySlugInput.'
);
$query = Recipe::query();
if ($input->includeTrashed) {
$query->withTrashed();
}
/** @var Recipe|null $recipe */
$recipe = match (true) {
$input instanceof FindRecipeByIdInput => $query->where('id', (string) $input->id)->first(),
$input instanceof FindRecipeBySlugInput => $query->where('slug', $input->slug)->first(),
};
if ($recipe === null) {
return null;
}
return RecipeDto::fromModel($recipe, $this->publicS3Disk);
}
}

View file

@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Recipes;
use App\Contracts\Query;
use App\Contracts\QueryInput;
use App\Models\Recipe;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Domain\Models\ValueObjects\Recipes\ConsumptionTime;
use Domain\Models\ValueObjects\Recipes\Inspiration;
use Domain\Models\ValueObjects\Recipes\Season;
use Illuminate\Database\Eloquent\Builder;
use SortDirection;
use Webmozart\Assert\Assert;
/**
* phpcs:disable Generic.Files.LineLength
*
* @implements \App\Contracts\Query<\App\Cqs\Queries\Recipes\GetBoPaginatedRecipesQueryInput, \App\Cqs\Queries\Recipes\GetBoPaginatedRecipesQueryOutput>
* phpcs:enable
*/
final readonly class GetBoPaginatedRecipesQuery implements Query
{
private const array SORTABLE_COLUMNS = [
'name',
'product_category',
'consomption_time',
'inspiration',
'season',
'is_published',
'created_at',
'updated_at',
];
#[\Override]
public function __invoke(QueryInput $input): GetBoPaginatedRecipesQueryOutput
{
Assert::isAOf($input, GetBoPaginatedRecipesQueryInput::class);
/** @var Builder<Recipe> $query */
$query = Recipe::query();
if ($input->isTrash) {
$query->onlyTrashed();
}
if ($input->search !== null && $input->search !== '') {
$words = array_filter(explode(' ', $input->search));
$query->where(function (Builder $q) use ($words, $input): void {
foreach ($words as $word) {
$q->where('name', 'like', "%{$word}%");
}
$q->orWhere('name', 'like', "%{$input->search}%");
});
}
$sortCol = in_array($input->sortCol, self::SORTABLE_COLUMNS, true)
? $input->sortCol
: 'name';
$direction = $input->sortDirection === SortDirection::Descending ? 'desc' : 'asc';
$query->orderBy($sortCol, $direction);
$paginated = $query->paginate(
perPage: $input->pagination->pageSize(),
page: $input->pagination->pageNumber(),
)->withQueryString();
$searchFields = \implode(', ', [
trans('validation.attributes.name'),
]);
return new GetBoPaginatedRecipesQueryOutput(
paginatedRecipes: $paginated,
search: $input->search,
searchFields: $searchFields,
categories: ProductCategory::toArray(),
consumptionTimes: ConsumptionTime::toArray(),
inspirations: Inspiration::toArray(),
seasons: Season::toArray(),
);
}
}

View file

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Recipes;
use App\Contracts\QueryInput;
use Domain\Models\ValueObjects\Pagination;
use SortDirection;
final readonly class GetBoPaginatedRecipesQueryInput implements QueryInput
{
public function __construct(
public Pagination $pagination,
public ?string $search = null,
public string $sortCol = 'name',
public SortDirection $sortDirection = SortDirection::Ascending,
public bool $isTrash = false,
) {
}
}

View file

@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Recipes;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
final readonly class GetBoPaginatedRecipesQueryOutput
{
/**
* @param LengthAwarePaginator<int,\App\Models\Recipe> $paginatedRecipes
* @param array<string, mixed> $categories
* @param array<string, mixed> $consumptionTimes
* @param array<string, mixed> $inspirations
* @param array<string, mixed> $seasons
*/
public function __construct(
public LengthAwarePaginator $paginatedRecipes,
public ?string $search,
public string $searchFields,
public array $categories,
public array $consumptionTimes,
public array $inspirations,
public array $seasons,
) {
}
}

View file

@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Recipes;
use App\Contracts\Directories\RecipeDirectory;
use App\Contracts\Query;
use App\Contracts\QueryInput;
use App\Dto\RecipeDto;
use App\Lib\PublicS3Disk;
use Domain\Models\ValueObjects\Pagination;
use Illuminate\Pagination\LengthAwarePaginator;
use Webmozart\Assert\Assert;
/**
* phpcs:disable Generic.Files.LineLength.TooLong
*
* @implements \App\Contracts\Query<\App\Cqs\Queries\Recipes\GetFrontRecipesQueryInput, \Illuminate\Pagination\LengthAwarePaginator<array-key, \App\Dto\RecipeDto>>
*
* phpcs:enable
*/
final readonly class GetFrontRecipesQuery implements Query
{
private const int PER_PAGE = 100;
public function __construct(
private RecipeDirectory $recipeDirectory,
private PublicS3Disk $publicS3Disk,
) {
}
/**
* @return LengthAwarePaginator<array-key, \App\Dto\RecipeDto>
*/
public function __invoke(QueryInput $input): LengthAwarePaginator
{
Assert::isAOf($input, GetFrontRecipesQueryInput::class);
// * Load all recipes for client-side display
$allRecipes = [];
$page = 1;
do {
$pagination = Pagination::ofPage($page, self::PER_PAGE);
$paginatedRecipes = $this->recipeDirectory->getPaginatedRecipes(
pagination: $pagination,
productRanges: $input->categories,
consumptionTimes: $input->consumptionTimes,
inspirations: $input->inspirations,
seasons: $input->seasons,
search: $input->search
);
foreach ($paginatedRecipes->items() as $recipe) {
$allRecipes[] = RecipeDto::fromEntity($recipe, $this->publicS3Disk);
}
$page++;
} while ($page <= $paginatedRecipes->lastPage());
return new LengthAwarePaginator(
items: $allRecipes,
total: count($allRecipes),
perPage: max(1, count($allRecipes)),
currentPage: 1,
);
}
}

View file

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Recipes;
use App\Contracts\QueryInput;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Domain\Models\ValueObjects\Recipes\ConsumptionTime;
use Domain\Models\ValueObjects\Recipes\Inspiration;
use Domain\Models\ValueObjects\Recipes\Season;
use Webmozart\Assert\Assert;
final readonly class GetFrontRecipesQueryInput implements QueryInput
{
/**
* @param ProductCategory[] $categories
* @param ConsumptionTime[] $consumptionTimes
* @param Inspiration[] $inspirations
* @param Season[] $seasons
*/
public function __construct(
public array $categories = [],
public array $consumptionTimes = [],
public array $inspirations = [],
public array $seasons = [],
public string $search = '',
) {
Assert::allIsAOf($this->categories, ProductCategory::class);
Assert::allIsAOf($this->consumptionTimes, ConsumptionTime::class);
Assert::allIsAOf($this->inspirations, Inspiration::class);
Assert::allIsAOf($this->seasons, Season::class);
}
}

View file

@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Recipes;
use App\Contracts\Directories\RecipeDirectory;
use App\Contracts\Query;
use App\Contracts\QueryInput;
use App\Dto\RecipeDto;
use App\Lib\PublicS3Disk;
use Domain\Models\Entities\Recipe as DomainRecipe;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Query<\App\Cqs\Queries\Recipes\GetRelatedRecipesQueryInput, array<\App\Dto\RecipeDto>>
*/
final readonly class GetRelatedRecipesQuery implements Query
{
public function __construct(
private RecipeDirectory $recipeDirectory,
private PublicS3Disk $publicS3Disk,
) {
}
/**
* @return array<RecipeDto>
*/
public function __invoke(QueryInput $input): array
{
Assert::isAOf($input, GetRelatedRecipesQueryInput::class);
$recipes = $this->recipeDirectory->getRelatedRecipes($input->productId, $input->limit);
return array_map(
fn (DomainRecipe $recipe): RecipeDto => RecipeDto::fromEntity(
$recipe,
$this->publicS3Disk
),
$recipes
);
}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Cqs\Queries\Recipes;
use App\Contracts\QueryInput;
use Domain\Models\ValueObjects\Entities\ProductId;
use Webmozart\Assert\Assert;
final readonly class GetRelatedRecipesQueryInput implements QueryInput
{
public function __construct(
public ProductId $productId,
public int $limit = 10,
) {
Assert::greaterThan($this->limit, 0);
}
}

View file

@ -1,228 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Directories;
use App\Contracts\Directories\ProductDirectory;
use App\Dto\CategoryProductsDto;
use App\Models\Product;
use App\Models\Recipe;
use Domain\Models\Entities\Product as DomainProduct;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Models\ValueObjects\Entities\RecipeId;
use Domain\Models\ValueObjects\Pagination;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Domain\Models\ValueObjects\Products\ProductSummary;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
class EloquentProductDirectory implements ProductDirectory
{
public function find(ProductId $id): ?DomainProduct
{
return Product::query()
->with(['recipes'])
->where('id', (string) $id->value)
->limit(1)
->get()
->map(fn (Product $product) => $this->mapToEntity($product))
->first();
}
public function findBySlug(string $slug): ?DomainProduct
{
return Product::query()
->with(['recipes'])
->where('slug', $slug)
->limit(1)
->get()
->map(fn (Product $product) => $this->mapToEntity($product))
->first();
}
public function getAllProductsSummaries(): array
{
return Product::query()
->select(['id', 'name', 'picture'])
->orderBy('name', 'asc')
->get()
->map(fn (Product $product) => $this->mapToSummary($product))
->all();
}
public function getPaginatedProducts(
Pagination $pagination,
string $search = '',
): LengthAwarePaginator {
$dbQueryCallback = function (Builder $query) {
/** @var \Illuminate\Database\Eloquent\Builder<Product> $query */
$query->with(['recipes']);
};
if (! empty($search)) {
$scout = Product::search($search)->query($dbQueryCallback);
/** @var \Illuminate\Pagination\LengthAwarePaginator<array-key,Product> $paginated */
$paginated = $scout->paginate(
$pagination->pageSize(),
'page',
$pagination->pageNumber()
);
return $paginated->through(fn ($product) => $this->mapToEntity($product));
}
/** @var \Illuminate\Database\Eloquent\Builder<Product> $query */
$query = Product::query();
$dbQueryCallback($query);
return $query->paginate(
perPage: $pagination->pageSize(),
page: $pagination->pageNumber()
)->through(fn (Product $product) => $this->mapToEntity($product));
}
/**
* @param \Domain\Models\ValueObjects\Products\BreedingMethod[] $breedingMethods
* @param \Domain\Models\ValueObjects\Products\Technology[] $technologies
* @param \Domain\Models\ValueObjects\Products\Business[] $businesses
*/
public function getCategoryProducts(
ProductCategory $category,
array $breedingMethods = [],
array $technologies = [],
array $businesses = [],
?bool $isEgalim = null,
string $search = '',
int $limitPerCategory = 6
): ?CategoryProductsDto {
$dbQueryCallback = function (Builder $query) use ($category, $breedingMethods, $technologies, $businesses, $isEgalim) {
/** @var \Illuminate\Database\Eloquent\Builder<Product> $query */
$query->where('category', $category->value)
->where('is_published', true)
->with(['recipes']);
if (! empty($breedingMethods)) {
$query->where(function ($q) use ($breedingMethods) {
foreach ($breedingMethods as $method) {
$q->orWhereJsonContains('breeding_methods', $method->value);
}
});
}
if (! empty($technologies)) {
$techValues = array_map(fn ($t) => $t->value, $technologies);
$query->whereIn('technology', $techValues);
}
if (! empty($businesses)) {
$query->where(function ($q) use ($businesses) {
foreach ($businesses as $business) {
$q->orWhereJsonContains('businesses', $business->value);
}
});
}
if ($isEgalim === true) {
$query->where('is_egalim', true);
}
};
$query = Product::query();
if (! empty($search)) {
$scout = Product::search($search);
// Get all matching IDs from Meilisearch
$matchingIds = $scout->keys();
if ($matchingIds->isEmpty()) {
return null;
}
$query->whereIn('id', $matchingIds);
}
$dbQueryCallback($query);
$count = $query->count();
if ($count === 0) {
return null;
}
$products = (clone $query)->limit($limitPerCategory)
->get()
->map(fn (Product $product) => $this->mapToEntity($product))
->all();
return new CategoryProductsDto(
category: $category,
total: $count,
products: $products,
);
}
private function mapToSummary(Product $product): ProductSummary
{
return new ProductSummary(
id: (string) $product->id,
name: $product->name,
picture: $product->picture?->value,
);
}
private function mapToEntity(Product $product): DomainProduct
{
return new DomainProduct(
id: new ProductId(Uuid::fromString($product->id)),
name: $product->name,
slug: $product->slug,
category: $product->category,
technology: $product->technology,
isEgalim: $product->is_egalim,
isPublished: (bool) $product->is_published,
breedingMethods: $product->breeding_methods->all(),
businesses: $product->businesses->all(),
detailsAndAdvantages: $product->details_and_advantages,
formats: $product->formats->all(),
handlingAdvices: $product->handling_advices,
ingredients: $product->ingredients,
conservationBeforeOpening: $product->conservation_before_opening,
description: $product->description,
seoTitle: $product->seo_title,
seoDescription: $product->seo_description,
conservationAfterOpening: $product->conservation_after_opening,
nutritionalValues: $product->nutritional_values,
picture: $product->picture?->value,
pictureAlt: $product->picture_alt,
pictureTitle: $product->picture_title,
pdfFilePath: $product->pdf?->value,
associatedRecipes: $product->relationLoaded('recipes')
? $product->recipes->map(
fn (Recipe $recipe): UuidInterface => $recipe->getId()->value
)->all()
: [],
);
}
public function getRelatedProducts(RecipeId $recipeId, int $limit = 4): array
{
$recipe = Recipe::query()
->where('id', $recipeId->value)
->first();
if (! $recipe) {
return [];
}
return $recipe->products()
->where('is_published', true)
->limit($limit)
->get()
->map(fn (Product $product) => $this->mapToEntity($product))
->all();
}
}

View file

@ -1,143 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Directories;
use App\Contracts\Directories\RecipeDirectory;
use App\Models\Product;
use App\Models\Recipe;
use Domain\Models\Entities\Recipe as DomainRecipe;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Models\ValueObjects\Entities\RecipeId;
use Domain\Models\ValueObjects\Pagination;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Ramsey\Uuid\Uuid;
class EloquentRecipeDirectory implements RecipeDirectory
{
public function find(RecipeId $id): ?DomainRecipe
{
return Recipe::query()
->with(['products'])
->where('id', (string) $id->value)
->limit(1)
->get()
->map(fn (Recipe $recipe) => $this->mapToEntity($recipe))
->first();
}
public function findBySlug(string $slug): ?DomainRecipe
{
return Recipe::query()
->with(['products'])
->where('slug', $slug)
->limit(1)
->get()
->map(fn (Recipe $recipe) => $this->mapToEntity($recipe))
->first();
}
public function getPaginatedRecipes(
Pagination $pagination,
array $productRanges = [],
array $consumptionTimes = [],
array $inspirations = [],
array $seasons = [],
string $search = ''
): LengthAwarePaginator {
$dbQueryCallback = function (Builder $query) use ($productRanges, $consumptionTimes, $inspirations, $seasons) {
/** @var \Illuminate\Database\Eloquent\Builder<Recipe> $query */
$query->with(['products'])
->where('is_published', true);
if (! empty($productRanges)) {
$rangesValues = array_map(fn ($r) => $r->value, $productRanges);
$query->whereIn('product_category', $rangesValues);
}
if (! empty($consumptionTimes)) {
$timesValues = array_map(fn ($t) => $t->value, $consumptionTimes);
$query->whereIn('consomption_time', $timesValues);
}
if (! empty($inspirations)) {
$inspValues = array_map(fn ($i) => $i->value, $inspirations);
$query->whereIn('inspiration', $inspValues);
}
if (! empty($seasons)) {
$seasonValues = array_map(fn ($s) => $s->value, $seasons);
$query->whereIn('season', $seasonValues);
}
};
$query = Recipe::query();
if (! empty($search)) {
$scout = Recipe::search($search);
$matchingIds = $scout->keys();
if ($matchingIds->isEmpty()) {
/** @var \Illuminate\Pagination\LengthAwarePaginator<array-key,DomainRecipe> */
return $query->whereIn('id', [])->paginate(
perPage: $pagination->pageSize(),
page: $pagination->pageNumber()
);
}
$query->whereIn('id', $matchingIds);
}
$dbQueryCallback($query);
return $query->paginate(
perPage: $pagination->pageSize(),
page: $pagination->pageNumber()
)->through(fn (Recipe $recipe) => $this->mapToEntity($recipe));
}
private function mapToEntity(Recipe $recipe): DomainRecipe
{
return new DomainRecipe(
id: new RecipeId(Uuid::fromString($recipe->id)),
name: $recipe->name,
slug: $recipe->slug,
isPublished: (bool) $recipe->is_published,
picture: $recipe->picture?->value,
seoTitle: $recipe->seo_title,
seoDescription: $recipe->seo_description,
productRange: $recipe->product_category,
consomptionTime: $recipe->consomption_time,
inspiration: $recipe->inspiration,
season: $recipe->season,
description: $recipe->description,
details: $recipe->details,
preparationTime: '15 min de préparation',
guestsCount: 4,
ingredientsList: $recipe->ingredients_list,
preparationSteps: $recipe->preparation_steps,
tipsAndAdvices: $recipe->tips_and_advices,
associatedProducts: []
);
}
public function getRelatedRecipes(ProductId $productId, int $limit = 4): array
{
$product = Product::query()
->where('id', (string) $productId->value)
->first();
if (! $product) {
return [];
}
return $product->recipes()
->where('is_published', true)
->limit($limit)
->get()
->map(fn (Recipe $recipe) => $this->mapToEntity($recipe))
->all();
}
}

View file

@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Dto;
use App\Lib\PublicS3Disk;
use Domain\Models\Entities\Product;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Illuminate\Support\Arr;
use Override;
final readonly class CategoryProductsDto implements \JsonSerializable
{
public function __construct(
public ProductCategory $category,
public int $total,
/** @var \Domain\Models\Entities\Product[] */
public array $products,
) {
}
#[Override]
public function jsonSerialize(): mixed
{
return [
'category' => [
'name' => $this->category->name(),
'label' => $this->category->label(),
'value' => $this->category->value(),
],
'total' => $this->total,
'products' => array_map(
[$this, 'productsToArray'],
$this->products
),
];
}
/** @return array<array-key,mixed> */
private function productsToArray(Product $product): array
{
$publicS3Disk = app(PublicS3Disk::class);
$data = Arr::wrap(json_decode(json_encode($product) ?: '', true));
if (isset($data['picture']) && is_string($data['picture'])) {
$data['picture'] = $publicS3Disk->publicUrlOf($data['picture']);
}
if (isset($data['pdfFilePath']) && is_string($data['pdfFilePath'])) {
$data['pdfFilePath'] = $publicS3Disk->publicUrlOf($data['pdfFilePath']);
}
return array_merge(
$data,
[
'id' => (string) $product->id->value,
]
);
}
}

View file

@ -1,242 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Dto;
use App\Lib\PublicS3Disk;
use App\Models\Product;
use Carbon\CarbonInterface;
use Domain\Models\Entities\Product as DomainProduct;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Models\ValueObjects\Products\NutritionalValues;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Domain\Models\ValueObjects\Products\Technology;
use Illuminate\Contracts\Routing\UrlRoutable;
use Illuminate\Contracts\Support\Arrayable;
use Ramsey\Uuid\Uuid;
/**
* @implements Arrayable<string, mixed>
*/
final readonly class ProductDto implements \JsonSerializable, Arrayable, UrlRoutable
{
/**
* @param \Domain\Models\ValueObjects\Products\BreedingMethod[] $breedingMethods
* @param \Domain\Models\ValueObjects\Products\Business[] $businesses
* @param \Domain\Models\ValueObjects\Products\ProductFormat[] $formats
*/
public function __construct(
public string $id,
public string $seoTitle,
public string $seoDescription,
public string $name,
public ?string $picture,
public ?string $pictureUrl,
public string $pictureAlt,
public string $pictureTitle,
public string $slug,
public ProductCategory $category,
public Technology $technology,
public bool $isEgalim,
public bool $isPublished,
public array $breedingMethods,
public array $businesses,
public array $formats,
public string $description,
public string $detailsAndAdvantages,
public string $handlingAdvices,
public ?string $ingredients,
public string $conservationBeforeOpening,
public ?string $conservationAfterOpening,
public ?NutritionalValues $nutritionalValues,
public ?string $pdf,
public ?string $pdfUrl,
public ?CarbonInterface $createdAt,
public ?CarbonInterface $updatedAt,
public ?CarbonInterface $deletedAt,
) {
}
public static function fromModel(Product $product, PublicS3Disk $publicS3Disk): self
{
$picturePath = $product->picture?->value;
$pdfPath = $product->pdf?->value;
return new self(
id: (string) $product->id,
seoTitle: (string) $product->seo_title,
seoDescription: (string) $product->seo_description,
name: (string) $product->name,
picture: $picturePath,
pictureUrl: $picturePath !== null ? $publicS3Disk->publicUrlOf($picturePath) : null,
pictureAlt: (string) $product->picture_alt,
pictureTitle: (string) $product->picture_title,
slug: (string) $product->slug,
category: $product->category,
technology: $product->technology,
isEgalim: (bool) $product->is_egalim,
isPublished: (bool) $product->is_published,
breedingMethods: $product->breeding_methods->values()->all(),
businesses: $product->businesses->values()->all(),
formats: $product->formats->values()->all(),
description: (string) $product->description,
detailsAndAdvantages: (string) $product->details_and_advantages,
handlingAdvices: (string) $product->handling_advices,
ingredients: $product->ingredients,
conservationBeforeOpening: (string) $product->conservation_before_opening,
conservationAfterOpening: $product->conservation_after_opening,
nutritionalValues: $product->nutritional_values,
pdf: $pdfPath,
pdfUrl: $pdfPath !== null ? $publicS3Disk->publicUrlOf($pdfPath) : null,
createdAt: $product->created_at,
updatedAt: $product->updated_at,
deletedAt: $product->deleted_at,
);
}
public static function fromEntity(
DomainProduct $product,
PublicS3Disk $publicS3Disk
): self {
$picturePath = $product->picture;
$pdfPath = $product->pdfFilePath;
return new self(
id: (string) $product->id->value,
seoTitle: (string) ($product->seoTitle ?? ''),
seoDescription: (string) ($product->seoDescription ?? ''),
name: $product->name,
picture: $picturePath,
pictureUrl: $picturePath !== null ? $publicS3Disk->publicUrlOf($picturePath) : null,
pictureAlt: (string) ($product->pictureAlt ?? ''),
pictureTitle: (string) ($product->pictureTitle ?? ''),
slug: $product->slug,
category: $product->category,
technology: $product->technology,
isEgalim: $product->isEgalim,
isPublished: $product->isPublished,
breedingMethods: $product->breedingMethods,
businesses: $product->businesses,
formats: $product->formats,
description: $product->description,
detailsAndAdvantages: $product->detailsAndAdvantages,
handlingAdvices: $product->handlingAdvices,
ingredients: $product->ingredients,
conservationBeforeOpening: $product->conservationBeforeOpening,
conservationAfterOpening: $product->conservationAfterOpening,
nutritionalValues: $product->nutritionalValues,
pdf: $pdfPath,
pdfUrl: $pdfPath !== null ? $publicS3Disk->publicUrlOf($pdfPath) : null,
createdAt: null,
updatedAt: null,
deletedAt: null,
);
}
public static function dummyForZiggy(): self
{
return new self(
id: 'dummy-id',
seoTitle: 'dummy-seo-title',
seoDescription: 'dummy-seo-description',
name: 'dummy-name',
picture: null,
pictureUrl: null,
pictureAlt: 'dummy-picture-alt',
pictureTitle: 'dummy-picture-title',
slug: 'dummy-slug',
category: ProductCategory::OMELETS_AND_DELI_AND_DESSERT_LINES,
technology: Technology::FRESH,
isEgalim: false,
isPublished: true,
breedingMethods: [],
businesses: [],
formats: [],
description: 'dummy-description',
detailsAndAdvantages: 'dummy-details-and-advantages',
handlingAdvices: 'dummy-handling-advices',
ingredients: 'dummy-ingredients',
conservationBeforeOpening: 'dummy-conservation-before-opening',
conservationAfterOpening: 'dummy-conservation-after-opening',
nutritionalValues: null,
pdf: null,
pdfUrl: null,
createdAt: null,
updatedAt: null,
deletedAt: null,
);
}
public function getId(): ProductId
{
return new ProductId(Uuid::fromString($this->id));
}
#[\Override]
public function getRouteKey(): mixed
{
return $this->id;
}
#[\Override]
public function getRouteKeyName(): string
{
return 'id';
}
#[\Override]
public function resolveRouteBinding($value, $field = null): mixed
{
return null;
}
#[\Override]
public function resolveChildRouteBinding($childType, $value, $field): mixed
{
return null;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'seo_title' => $this->seoTitle,
'seo_description' => $this->seoDescription,
'picture' => $this->pictureUrl,
'picture_alt' => $this->pictureAlt,
'picture_title' => $this->pictureTitle,
'category' => $this->category->value,
'technology' => $this->technology->value,
'is_egalim' => $this->isEgalim,
'is_published' => $this->isPublished,
'breeding_methods' => $this->breedingMethods,
'businesses' => $this->businesses,
'formats' => $this->formats,
'description' => $this->description,
'details_and_advantages' => $this->detailsAndAdvantages,
'handling_advices' => $this->handlingAdvices,
'ingredients' => $this->ingredients,
'conservation_before_opening' => $this->conservationBeforeOpening,
'conservation_after_opening' => $this->conservationAfterOpening,
'nutritional_values' => $this->nutritionalValues?->toArray(),
'pdf' => $this->pdfUrl,
'created_at' => $this->createdAt?->toISOString(),
'updated_at' => $this->updatedAt?->toISOString(),
'deleted_at' => $this->deletedAt?->toISOString(),
];
}
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Dto;
final readonly class ProductSummaryDto
{
public function __construct(
public string $id,
public string $name,
public ?string $pictureUrl,
) {
}
}

View file

@ -1,208 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Dto;
use App\Lib\PublicS3Disk;
use App\Models\Recipe;
use Carbon\CarbonInterface;
use Domain\Models\Entities\Recipe as DomainRecipe;
use Domain\Models\ValueObjects\Entities\RecipeId;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Domain\Models\ValueObjects\Recipes\ConsumptionTime;
use Domain\Models\ValueObjects\Recipes\Inspiration;
use Domain\Models\ValueObjects\Recipes\Season;
use Illuminate\Contracts\Routing\UrlRoutable;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
use Ramsey\Uuid\Uuid;
/**
* @implements Arrayable<string, mixed>
*/
final readonly class RecipeDto implements Arrayable, JsonSerializable, UrlRoutable
{
public function __construct(
public string $id,
public string $seoTitle,
public string $seoDescription,
public string $name,
public ?string $picture,
public ?string $pictureUrl,
public string $pictureAlt,
public string $pictureTitle,
public string $slug,
public bool $isPublished,
public ProductCategory $productCategory,
public ConsumptionTime $consomptionTime,
public Inspiration $inspiration,
public Season $season,
public ?string $description,
public ?string $details,
public string $ingredientsList,
public string $preparationSteps,
public ?string $tipsAndAdvices,
public ?CarbonInterface $createdAt,
public ?CarbonInterface $updatedAt,
public ?CarbonInterface $deletedAt,
) {
}
public static function fromModel(Recipe $recipe, PublicS3Disk $publicS3Disk): self
{
$picturePath = $recipe->picture?->value;
return new self(
id: (string) $recipe->id,
seoTitle: (string) $recipe->seo_title,
seoDescription: (string) $recipe->seo_description,
name: (string) $recipe->name,
picture: $picturePath,
pictureUrl: $picturePath !== null ? $publicS3Disk->publicUrlOf($picturePath) : null,
pictureAlt: (string) $recipe->picture_alt,
pictureTitle: (string) $recipe->picture_title,
slug: (string) $recipe->slug,
isPublished: (bool) $recipe->is_published,
productCategory: $recipe->product_category,
consomptionTime: $recipe->consomption_time,
inspiration: $recipe->inspiration,
season: $recipe->season,
description: $recipe->description,
details: $recipe->details,
ingredientsList: (string) $recipe->ingredients_list,
preparationSteps: (string) $recipe->preparation_steps,
tipsAndAdvices: $recipe->tips_and_advices,
createdAt: $recipe->created_at,
updatedAt: $recipe->updated_at,
deletedAt: $recipe->deleted_at,
);
}
public static function fromEntity(
DomainRecipe $recipe,
PublicS3Disk $publicS3Disk
): self {
$picturePath = $recipe->picture;
return new self(
id: (string) $recipe->id->value,
seoTitle: (string) ($recipe->seoTitle ?? ''),
seoDescription: (string) ($recipe->seoDescription ?? ''),
name: $recipe->name,
picture: $picturePath,
pictureUrl: $picturePath !== null ? $publicS3Disk->publicUrlOf($picturePath) : null,
pictureAlt: '',
pictureTitle: '',
slug: $recipe->slug,
isPublished: $recipe->isPublished,
productCategory: $recipe->productRange,
consomptionTime: $recipe->consomptionTime,
inspiration: $recipe->inspiration,
season: $recipe->season,
description: $recipe->description,
details: $recipe->details,
ingredientsList: $recipe->ingredientsList,
preparationSteps: $recipe->preparationSteps,
tipsAndAdvices: $recipe->tipsAndAdvices,
createdAt: null,
updatedAt: null,
deletedAt: null,
);
}
public static function dummyForZiggy(): self
{
return new self(
id: 'dummy-id',
seoTitle: 'dummy-seo-title',
seoDescription: 'dummy-seo-description',
name: 'dummy-name',
picture: null,
pictureUrl: null,
pictureAlt: 'dummy-picture-alt',
pictureTitle: 'dummy-picture-title',
slug: 'dummy-slug',
isPublished: true,
productCategory: ProductCategory::OMELETS_AND_DELI_AND_DESSERT_LINES,
consomptionTime: ConsumptionTime::LUNCH_DINNER,
inspiration: Inspiration::FRENCH_COOKING,
season: Season::ALL_YEAR,
description: 'dummy-description',
details: 'dummy-details',
ingredientsList: 'dummy-ingredients-list',
preparationSteps: 'dummy-preparation-steps',
tipsAndAdvices: 'dummy-tips-and-advices',
createdAt: null,
updatedAt: null,
deletedAt: null,
);
}
public function getId(): RecipeId
{
return new RecipeId(Uuid::fromString($this->id));
}
#[\Override]
public function getRouteKey(): mixed
{
return $this->id;
}
#[\Override]
public function getRouteKeyName(): string
{
return 'id';
}
#[\Override]
public function resolveRouteBinding($value, $field = null): mixed
{
return null;
}
#[\Override]
public function resolveChildRouteBinding($childType, $value, $field): mixed
{
return null;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'is_published' => $this->isPublished,
'seo_title' => $this->seoTitle,
'seo_description' => $this->seoDescription,
'picture' => $this->pictureUrl,
'picture_alt' => $this->pictureAlt,
'picture_title' => $this->pictureTitle,
'product_category' => $this->productCategory->value,
'consomption_time' => $this->consomptionTime->value,
'inspiration' => $this->inspiration->value,
'season' => $this->season->value,
'description' => $this->description,
'details' => $this->details,
'ingredients_list' => $this->ingredientsList,
'preparation_steps' => $this->preparationSteps,
'tips_and_advices' => $this->tipsAndAdvices,
'created_at' => $this->createdAt?->toISOString(),
'updated_at' => $this->updatedAt?->toISOString(),
'deleted_at' => $this->deletedAt?->toISOString(),
];
}
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}

View file

@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums\Contact;
use Uwil\LaravelCommon\Traits\Enums\BaseEnum;
enum ActivitySectorEnum: int
{
use BaseEnum;
case commercial_catering = 1;
case collective_catering = 2;
case bakery_pastry = 3;
case food_industry = 4;
case supermarkets = 5;
case others = 6;
/**
* Optionnal labels definition.
*
* @phpstan-ignore classConstant.unused
*/
private const LABELS = [
self::commercial_catering->name => 'Restauration Commerciale',
self::collective_catering->name => 'Restauration Collective',
self::bakery_pastry->name => 'Boulangerie-Pâtisserie',
self::food_industry->name => 'Industrie Agro-Alimentaire',
self::supermarkets->name => 'Grandes et Moyennes Surfaces',
self::others->name => 'Autres',
];
}

View file

@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums\Contact;
use Uwil\LaravelCommon\Traits\Enums\BaseEnum;
enum ObjectEnum: int
{
use BaseEnum;
case product_questions = 1;
case quote_request = 2;
case order_tracking = 3;
case partnership_request = 4;
case product_complaint = 5;
case other = 6;
/**
* Optionnal labels definition.
*
* @phpstan-ignore classConstant.unused
*/
private const LABELS = [
self::product_questions->name => 'Questions sur nos produits',
self::quote_request->name => 'Demande de devis',
self::order_tracking->name => 'Suivi de commande',
self::partnership_request->name => 'Demande de partenariat',
self::product_complaint->name => 'Réclamation produit',
self::other->name => 'Autre',
];
}

View file

@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
class AuthServiceException extends \RuntimeException
{
public static function of(?string $message = null, ?\Throwable $cause = null): self
{
return new self($message ?? 'An authentication error occurred.', previous: $cause);
}
}

View file

@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Helpers;
class FileStorageOperationException extends \RuntimeException
{
public static function copyFailed(string $sourcePath, string $destinationPath): self
{
return new self(sprintf('Failed to copy file %s to %s', $sourcePath, $destinationPath));
}
public static function cannotGenerateUniqueFilename(string $folderPath, string $filename): self
{
return new self("Failed to have a unique filename for {$folderPath}/{$filename}");
}
}

View file

@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Helpers;
class UnsupportedStructureConversionException extends \RuntimeException
{
public static function fromType(mixed $input): self
{
return new self('Cannot handle $input of type ' . \gettype($input));
}
}

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