Compare commits

...
Sign in to create a new pull request.

188 commits

Author SHA1 Message Date
7c80ac1f11
refactor(seeders): simplify product synchronization logic
Replaced conditional with a more concise when() function for syncing products.
2026-10-20 15:39:00 +02:00
dd6eeb558a
feat(seeders): add recipe synchronization with products
Implement functionality to sync associated recipes for products during CSV import.
2026-10-20 15:34:36 +02:00
b9a0aedceb
refactor(seeders): streamline product and recipe import logic
Refactored the product and recipe import logic to enhance readability and maintainability.
2026-10-20 15:17:26 +02:00
f0ca2622d6
feat(products): add conservation instructions to product details
Added handling for conservation instructions before and after opening, ensuring they are displayed only when available.
2026-10-20 12:43:24 +02:00
a8bf4ea681
style(components): add containerClassName to Section components
Updated Section components to include containerClassName for improved layout consistency.
2026-10-20 12:32:14 +02:00
597e1c720a
refactor: add missing nutritional values on products 2026-10-20 12:23:15 +02:00
325c7a7fd6
style(inputs): update layout alignment in SimpleString component
Adjusted the alignment from justify-between to justify-end for better layout consistency.
2026-10-20 09:18:45 +02:00
764e58b61b
style(inputs): update Label component to use font-bold class
Enhances the visual emphasis of the label in the CompleteInputFile component.
2026-10-20 09:16:47 +02:00
345d0cfbfd
style(inputs): update Label component to use font-bold class
Enhances the visibility of the label in the MultiSelect component.
2026-10-20 09:15:18 +02:00
7482e8cebc
refactor(wysiwyg): improve editor initialization and styling
- Updated editor initialization to use useEditor hook.
- Enhanced styling for better responsiveness and focus handling.
2026-10-20 09:15:04 +02:00
cd2d8ed397
refactor(i18n): replace GetTranslation with trans function
- Updated translation utility to improve consistency and reduce redundancy.
- Removed deprecated useTrans hook and related code.
- Introduced a new Zustand store for managing translations and locale settings.
- Cleaned up unused hooks and files related to localization.
2026-10-19 17:43:33 +02:00
1d628201a6
style(inputs): update Label components to use font-bold class
This change enhances the visual consistency of input labels across various components.
2026-10-19 16:24:15 +02:00
93beae7ade
refactor(inputs): replace Button with span in ModelSelect and MultiSelect
Updated the button elements to span for better semantic usage without altering functionality.
2026-10-19 15:43:36 +02:00
cad48f967c
style(products): adjust button and heading alignment
style(recipes): update button and heading alignment
2026-10-19 15:07:34 +02:00
d4e46b187c
feat: add SEO fields to product and page components
Added seoTitle and seoDescription fields to the Product model and updated relevant components to utilize these fields for improved SEO.
2026-10-19 14:55:17 +02:00
455ec468d8
refactor(seeder): simplify enum value retrieval logic
Updated the methods for retrieving enum values to improve readability and maintainability.
2026-10-19 14:55:03 +02:00
6cfa8d8e27
fix(breadcrumbs): handle missing product and recipe gracefully
Return early instead of throwing exceptions for missing product or recipe
during breadcrumb generation to improve user experience.
2026-10-19 14:39:04 +02:00
b989a45864
refactor: update products csv file and parser 2026-10-19 13:10:17 +02:00
363f0f15e2
feat(seeder): enhance product and recipe import functionality
- Added new columns to the product and recipe import CSVs.
- Implemented robust header validation and mapping for CSV files.
- Refactored data extraction methods for improved readability and maintainability.
- Enhanced error handling during product and recipe creation.
2026-10-19 13:03:27 +02:00
83c5ff3e6f
feat(products): add description field to product model and forms
- Added description field to Product model and database migration.
- Updated StoreProductRequest validation rules for description.
- Modified product factory and seeder to include description.
- Enhanced product forms and show pages to display description.
2026-10-19 11:38:58 +02:00
26c3ff1768
chore: update PHP executable paths and include paths
Removed deprecated PHP executable paths and added new include paths for Faker and Pest.
2026-10-19 11:33:35 +02:00
c35b65d76f
refactor: add "Sans élevage" on Breeding 2026-10-19 10:59:37 +02:00
ad6d2c3413
chore: update dependencies in package-lock.json
- Added @tiptap/extension-text-style version 3.32.0.
- Updated @commitlint packages to version 21.2.4.
- Updated other dependencies to their latest versions.
2026-10-19 10:41:49 +02:00
48cde3db8e
fix: add deleted_at column to users table 2026-10-16 18:04:57 +02:00
ae7e63b810
fix: register Uwil service providers explicitly 2026-10-16 17:43:37 +02:00
d082fd0ca1
Merge branch 'develop' into trunk 2026-10-16 17:26:00 +02:00
16e2040d5c
build: fix 2026-10-16 17:25:55 +02:00
3856151908
Merge branch 'develop' into trunk 2026-10-16 16:59:37 +02:00
4e670b04a4
build: fix 2026-10-16 16:59:28 +02:00
7cdcca687d
fix: add missing middlewares 2026-10-16 16:59:25 +02:00
1cf8f31886
Merge branch 'develop' into trunk 2026-10-16 16:34:25 +02:00
1b4052f088
build: fix 2026-10-16 16:34:16 +02:00
953bf34bb3
Merge branch 'develop' into trunk 2026-10-16 16:24:36 +02:00
f86bf54242
build: fix 2026-10-16 16:24:22 +02:00
7207109d63
build: fix 2026-10-16 15:34:22 +02:00
b2151a6e67
Merge branch 'develop' into trunk 2026-10-16 15:05:12 +02:00
c5e42e0f88
build: fix 2026-10-16 15:05:06 +02:00
a42b822546
Merge branch 'develop' into trunk 2026-10-16 14:46:56 +02:00
449064d5d4
build: fix build image and command 2026-10-16 14:46:27 +02:00
d2c7029779
Merge branch 'develop' into trunk 2026-10-16 14:20:02 +02:00
Jérémy
7a506c7e1a Merge branch 'FFFING_FIXES' into 'develop'
refactor: hudge bump...

See merge request ldc-web/projets/ovoteam!39
2026-10-16 14:19:19 +02:00
3331db4c6c
ci: fix 2026-10-16 14:16:42 +02:00
1316d05b78
ci: updateci file 2026-10-16 14:07:07 +02:00
Jeremy Munsch
9efcb32ee1
refactor: hudge bump !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!111
Fixes #59
2026-10-16 14:00:55 +02:00
Jeremy Munsch
9116a4bf88
fix: products layout 2026-10-16 10:28:11 +02:00
Jeremy Munsch
5117c8e445
refactor: recipe and product requests update 2026-10-16 09:47:39 +02:00
Jeremy Munsch
eb4bf9202b
chore: vscode config update 2026-10-16 09:47:13 +02:00
Jeremy Munsch
d641d61bfd
refactor: prevent crashing on front breadcrumbs 2026-10-16 09:46:45 +02:00
K Muller
2d1ade9d62 Merge branch 'develop' into trunk 2026-10-09 09:56:48 +02:00
Karl M'muller
acdc8fe365 Merge branch '35-update-assets' into 'develop'
feat: ✨ update IAA assets

Closes #35

See merge request ldc-web/projets/ovoteam!38
2026-10-09 07:54:53 +00:00
Corentin Huet
858711fa4f feat: ✨ update IAA assets 2026-10-06 15:26:49 +02:00
Noah Lancien
ee363196dc Merge branch 'develop' into trunk 2026-09-28 15:16:03 +02:00
Noah Lancien
ccaa4de15a fix: 🐛 added url for recipes and products pages 2026-09-28 15:14:52 +02:00
Noah Lancien
4b1bf703c2 Merge branch 'develop' into trunk 2026-09-25 16:13:30 +02:00
Noix
aa40b8a9e4 Merge branch 'issue-29' into 'develop'
style: 💄 wording and picto for commit page

See merge request ldc-web/projets/ovoteam!32
2026-09-25 14:12:36 +00:00
K Muller
3f5537731d style: 💄 wording and picto for commit page 2026-09-25 16:11:20 +02:00
Noah Lancien
6fbd8d82f4 Merge branch 'develop' into trunk 2026-09-25 15:58:52 +02:00
Noah Lancien
27c3d0e87e ci: Use 8.5 + node 24 in Dockerfile 2026-09-25 15:58:31 +02:00
Jeremy Munsch
03fdd79579
Merge branch 'develop' into trunk 2026-09-25 13:25:36 +02:00
Jeremy Munsch
03ffaf5093
refactor: lint fixes 2026-09-25 13:25:27 +02:00
Jeremy Munsch
fce0f69edb
Merge branch 'develop' into trunk 2026-09-25 13:00:21 +02:00
Jeremy Munsch
a9214091c7
chore: npm update 2026-09-25 12:59:50 +02:00
Noah Lancien
42514d9778 Merge branch 'develop' into trunk 2026-09-25 10:53:45 +02:00
Noix
58b8c7d874 Merge branch 'recipes-and-products' into 'develop'
Closes #22, Closes #23

Closes #22 and #23

See merge request ldc-web/projets/ovoteam!25
2026-09-25 08:50:19 +00:00
a57b100e27
chore: composer update hooks 2026-09-21 13:03:16 +02:00
f60ccf9909
chore: vscode settings 2026-09-21 13:03:00 +02:00
1137b191c5
chore: composer update 2026-09-21 13:02:47 +02:00
79cd52bcd6
chore: eslint fixes 2026-09-21 13:00:42 +02:00
c21a9b334b
fix: add missing front breadcrumb route for recipes 2026-09-21 11:52:41 +02:00
fd5992f65f
chore: modification des traductions pour les pages {recipes,products}.index avec les éléments fournis 2026-09-21 11:52:41 +02:00
36a44f0df3
refactor(ProductController, RecipeController): simplify data handling in store and update methods
Improved clarity by introducing a variable for request data before filling the model.
2026-09-21 11:52:40 +02:00
28f447c8ec
refactor: fron and back recipe - products association 2026-09-21 11:52:40 +02:00
b6d08c8bd1
refactor(CarouselContent): reorganize imports and improve pagination logic 2026-09-21 11:51:20 +02:00
d3014b1ac8
style(MobileSlider): adjust dot indicator sizes and spacing
Updated the height and margin of dot indicators for better visibility and alignment.
2026-09-21 11:50:41 +02:00
2610f91bec
style(vscode): update eslint validation for JavaScript files
Enhanced eslint validation to include 'javascriptreact' and 'typescriptreact' for better linting support.
2026-09-21 11:50:41 +02:00
5a7230ca9a
refactor(Recipes): enhance list formatting for ingredients and preparation steps
Improved the formatList function to convert plain text into structured lists.
2026-09-21 11:50:40 +02:00
04d038a61d
refactor(Recipes): replace 'create' and 'update' with 'viewAny' permission check
Updated permission checks in Create and Edit components to use 'viewAny' instead of 'create' and 'update'.
2026-09-21 11:50:40 +02:00
c0f5ae7a77
refactor(Recipes): simplify Form component structure
Removed unnecessary div wrapper around Form component in Create and Edit pages for cleaner code.
2026-09-21 11:50:40 +02:00
3e4201cb98
style(MobileSlider): adjust dot indicator styles and translation 2026-09-21 11:50:40 +02:00
86b0b3cbe3
style(Products): update button label for product view
Removed trailing '>' from button label for clarity.
2026-09-21 11:50:39 +02:00
822d71c640
refactor: remove unused queue methods and update type hints 2026-09-21 11:50:39 +02:00
33494d0360
refactor: phpcs,phpstan fixes with pint pass 2026-09-21 11:50:39 +02:00
5a24c6c8b7
refactor(Create): remove unused ProductModel import
This change cleans up the code by removing an unused import,
improving readability and maintainability.
2026-09-21 11:50:38 +02:00
026b511015
refactor(AppSidebar): remove unused authService import
Cleaned up the AppSidebar component by removing the unused authService import.
2026-09-21 11:50:38 +02:00
8315650407
style(eslint): update no-unused-vars rules for better clarity
Refine ESLint rules for unused variables to improve code quality.
2026-09-21 11:50:38 +02:00
524cba1070
refactor: eslint fixes 2026-09-21 11:50:37 +02:00
7abc85c3e3
feat(wysiwyg): enhance styling and structure for tiptap component
Updated the tiptap class with improved styles for headings, lists, and other elements.
Applied consistent spacing and formatting to enhance readability and presentation.
2026-09-21 11:47:38 +02:00
108e26f9c0
feat(sanitize): add HTML sanitization utility functions
- Introduced DOMPurify for sanitizing HTML inputs.
- Added `sanitizeRichHtml` and `toSanitizedHtml` functions for safe HTML rendering.
- Updated various components to use the new sanitization methods to prevent XSS attacks.
2026-09-21 11:47:38 +02:00
57a9db1fd5
fix(RecipeController): ensure perPage is at least 1 for pagination
This change prevents potential issues with pagination when there are no recipes.
2026-09-21 11:44:53 +02:00
9fc2690e74
feat(products): add mobile filter functionality and UI enhancements
- Implemented mobile filter toggle and drawer for product filters.
- Added MobileFilterGroup component for better filter management.
- Enhanced UI for active filter chips and search bar.
- Improved layout and styling for better responsiveness.

feat(recipes): introduce mobile filter functionality and UI updates

- Added mobile filter toggle and drawer for recipe filters.
- Created MobileFilterGroup component for recipe filtering.
- Updated layout and styling for improved user experience.
- Enhanced responsiveness for recipe display on mobile devices.
2026-09-21 11:44:53 +02:00
b20846f731
feat(ModelActions): add external link handling for front view actions
Enhance GetAction to open specific routes in a new tab with security attributes.
2026-09-21 11:44:52 +02:00
1ad8c00241
fix(link): update rel attribute to 'noopener noreferrer' for security 2026-09-21 11:44:52 +02:00
a1f8e410fe
feat(routes): add duplicate, restore, and force destroy routes for products and recipes
This update introduces new routes for duplicating, restoring, and force deleting products and recipes, enhancing the management capabilities in the application.
2026-09-21 11:43:40 +02:00
444ee46ac0
refactor(breadcrumbs): update product and recipe edit routes
Refactor breadcrumb generation for product and recipe edit routes
to ensure proper instance checks and improve error handling.
2026-09-21 11:43:39 +02:00
1a7f321cff
refactor: initial Recipes and products implementation ok 2026-09-21 11:43:39 +02:00
1a12569395
feat(directory): update ProductDirectory to use ProductId
- Changed the find method to accept ProductId instead of int.
- Added findBySlug method to retrieve products by slug.
- Updated getCategoryProducts method to return CategoryProducts type.

feat(eloquent): implement findBySlug in EloquentProductDirectory

- Implemented findBySlug method to retrieve products by slug.
- Updated find method to handle ProductId.

feat(dto): enhance CategoryProducts to serialize product IDs

- Updated CategoryProducts to serialize product IDs as strings.

feat(controller): modify ProductController to handle search limits

- Set limitPerCategory to -1 for search functionality.

refactor(model): change Product model ID to UUID

- Changed the id field in Product model to UUID.
- Updated related methods to handle UUIDs.

feat(migration): alter products and recipes tables for UUIDs

- Updated migrations to use UUIDs for primary keys in products and recipes tables.
- Adjusted foreign key constraints in product_recipe table.

feat(route): add route model binding for products

- Implemented route model binding for products using ProductId and slug.

chore(seed): remove CSV import calls from MandatorySeeder

- Removed calls to ImportProductsCsvSeeder and ImportRecipesCsvSeeder.

feat(entity): create ProductId and RecipeId value objects

- Added ProductId and RecipeId classes to encapsulate UUIDs.

feat(frontend): update TypeScript interfaces for products

- Updated TypeScript interfaces to reflect changes in product structure.
2026-09-21 11:43:39 +02:00
1200bfea4c
refactor: product.index page with search 2026-09-21 11:43:38 +02:00
e14c43d82f
refactor(routes): correct Inertia render method for demo route
Updated the Inertia render method for the demo route to ensure consistency in code style.
2026-09-21 11:37:14 +02:00
696bd9ed66
refactor(routes): restructure demo route for production check 2026-09-21 11:37:13 +02:00
028f769dbc
feat(products): add create and edit product pages
- Implemented Create and Edit pages for products with forms.
- Added validation and permissions handling for product actions.
- Introduced new product model and related types for better structure.
- Enhanced sidebar registry to remove auth dependency for link resolution.
- Updated routes to handle precognitive requests for product forms.
2026-09-21 11:36:52 +02:00
4139c25e42
feat(middlewares): add HtmlSanitizer and AuthServiceProvider
- Introduced HtmlSanitizer middleware for input sanitization.
- Added AuthServiceProvider for managing authorization policies.
2026-09-21 11:36:52 +02:00
213d0bb568
refactor: restructure application bootstrap files 2026-09-21 11:36:52 +02:00
43c3d55b72
refactor: products.index and recipes.index pages on Bo 2026-09-21 11:36:51 +02:00
a7992f96c8
chore: npm set package name 2026-09-21 11:36:50 +02:00
e39cd1ac61
chore: no emoji on commit message 2026-09-21 11:35:41 +02:00
86fdfa5560
fix: not found page 2026-09-21 11:35:41 +02:00
Jeremy Munsch
d29100bb61
refactor: add recipes and products with data migration from sharepoint doc 2026-09-21 11:35:41 +02:00
527a9f7664
chore: add vscode json elements 2026-09-21 11:35:40 +02:00
Jeremy Munsch
e365d93d1f
chore: pint, phpstanconfig phpcs changes 2026-09-21 11:35:40 +02:00
Jeremy Munsch
5e44470cf5
chore: bump to vite 8 and fix compilation issues 2026-09-21 11:35:40 +02:00
Noah Lancien
5c4670847e Merge branch 'develop' into trunk 2026-09-17 16:33:25 +02:00
Noix
8b85165d42 Merge branch 'fixUrl' into 'develop'
fix: 🐛 url social network

See merge request ldc-web/projets/ovoteam!36
2026-09-17 14:31:33 +00:00
Abdel LAMRANI
377be050da fix: 🐛 url social network 2026-09-15 12:56:19 +02:00
Vincent BRIDIER
f880a72b93 Merge branch 'restrict-demo-route' into 'develop'
fix: 🐛 restrict demo route to local env

See merge request ldc-web/projets/ovoteam!34
2026-09-01 13:05:57 +02:00
Corentin Huet
2ec086c7d7 fix: 🐛 restrict demo route to local env 2026-09-01 12:12:38 +02:00
K Muller
c2eb79882e Merge branch 'develop' into trunk 2026-08-20 10:17:13 +02:00
Karl M'muller
66be9ae6c0 Merge branch 'issue-30' into 'develop'
style: 💄 add picto to commitments

See merge request ldc-web/projets/ovoteam!33
2026-08-20 08:16:42 +00:00
K Muller
f2a59d8356 fix: 🐛 resolve conflicts 2026-08-20 10:14:16 +02:00
Karl M'muller
c3c3af0b03 Merge branch 'issue-31' into 'develop'
style: 💄 add slider product on commitment page

See merge request ldc-web/projets/ovoteam!29
2026-08-20 08:12:13 +00:00
K Muller
76edf3f575 feat: ✨ add labels to range page 2026-08-10 09:40:06 +02:00
K Muller
733a406a30 Merge branch 'develop' into trunk 2026-08-07 11:04:03 +02:00
Karl M'muller
c7ad4cb2ac Merge branch 'issue-27' into 'develop'
fix: 🐛 missing accent on 'a'

See merge request ldc-web/projets/ovoteam!31
2026-08-07 09:03:02 +00:00
K Muller
0319478cf1 fix: 🐛 missing accent on 'a' 2026-08-07 11:02:16 +02:00
K Muller
b995c2b054 Merge branch 'develop' into trunk 2026-08-06 16:22:40 +02:00
Karl M'muller
af232a5fd7 Merge branch 'issue-27' into 'develop'
style: 💄 change commitments section on homepage

See merge request ldc-web/projets/ovoteam!30
2026-08-06 14:21:35 +00:00
K Muller
0d310b0f23 style: 💄 change commitments section on homepage 2026-08-06 15:09:10 +02:00
K Muller
a2da6b0cce style: 💄 add slider product on commitment page 2026-08-06 10:24:34 +02:00
K Muller
fca2500680 Merge branch 'develop' into trunk 2026-08-05 15:42:42 +02:00
Karl M'muller
999995dbad Merge branch 'ts-3788' into 'develop'
style: 💄 commitment page content change

See merge request ldc-web/projets/ovoteam!27
2026-08-05 13:39:49 +00:00
Karl M'muller
7a23a63792 Merge branch 'issue-28' into 'develop'
fix: 💄 change wording + social media order

See merge request ldc-web/projets/ovoteam!28
2026-08-05 13:39:16 +00:00
K Muller
8fd6f539fd fix: 💄 change wording + social media order 2026-08-05 10:59:41 +02:00
K Muller
93dc422e5b style: 💄 commitment page content change 2026-08-04 17:36:12 +02:00
K Muller
e0e5de1145 Merge branch 'develop' into trunk 2026-08-04 17:04:12 +02:00
K Muller
178a8be7ef fix: 🐛 error on package.lock 2026-08-04 17:03:52 +02:00
Karl M'muller
8ab3701670 Merge branch 'issue-32' into 'develop'
feat: ✨ add label slider

See merge request ldc-web/projets/ovoteam!26
2026-08-04 14:52:29 +00:00
K Muller
132b4ba5b4 feat: ✨ add label slider 2026-08-04 15:57:20 +02:00
Jeremy Munsch
64e34ecc4f
fix: eslint 2026-06-26 12:28:24 +02:00
Jeremy Munsch
7f6b1aeb3d
chore: npm update and fix dependencies 2026-06-26 12:12:33 +02:00
Jeremy Munsch
47ce939b9e
fix: tsconfig baseUrl deprecated 2026-06-26 12:11:08 +02:00
Jeremy Munsch
b9c8d9a7f1
fix: incorrectly added Gotham font 2026-06-26 12:10:48 +02:00
Karl M'muller
92f53d2477 Merge branch 'develop' into 'trunk'
Develop

See merge request ldc-web/projets/ovoteam!23
2026-06-25 13:09:11 +00:00
Karl M'muller
f03749e20c Merge branch 'close-24' into 'develop'
Closes #24

See merge request ldc-web/projets/ovoteam!22
2026-06-25 13:07:40 +00:00
Jeremy Munsch
67b8a56126
fix: remove bullet points 2026-06-25 12:39:51 +02:00
Jeremy Munsch
54843370e2
refactor: change hero slide size factor and rendering 2026-06-25 12:28:19 +02:00
Jeremy Munsch
913a49b641
chore: add watch to package.json 2026-06-25 11:32:14 +02:00
Jeremy Munsch
d3fe410952
refactor: change cookinotice disposition in order to be iso as prod 2026-06-25 11:28:39 +02:00
Jeremy Munsch
c2516cc295
chore: update oeuf to Œuf 2026-06-25 11:21:23 +02:00
Jeremy Munsch
686b965f9b
fix: default page using un declared route 2026-06-25 11:15:18 +02:00
Jeremy Munsch
d2a3b61d75
fix: ignore breadcrumb route declarations for dynamic pages as a dirty patch 2026-06-25 10:05:31 +02:00
Jeremy Munsch
893f870735
chore: add public/favicon-back to gitignore 2026-06-25 10:04:22 +02:00
K Muller
f43cb28a7d Merge branch 'develop' into trunk 2026-06-04 09:05:19 +02:00
karl.muller
5874ea315a Merge branch '25-fix-footer-links' into 'develop'
fix: 🐛 fix wrong URL on footer links

See merge request ldc-web/projets/ovoteam!20
2026-06-04 07:04:47 +00:00
Corentin Huet
dea14e0491 fix: 🐛 fix wrong URL on footer links 2026-06-03 15:02:00 +02:00
K Muller
b2e6a25a92 Merge branch 'develop' into trunk 2026-06-01 10:48:33 +02:00
karl.muller
97e7d7e33a Merge branch 'mailer' into 'develop'
fix: 🐛 change default mailer to smtp

See merge request ldc-web/projets/ovoteam!18
2026-06-01 08:47:47 +00:00
K Muller
5bca572e67 Merge branch 'develop' into trunk 2026-05-28 11:56:09 +02:00
karl.muller
264be08046 Merge branch '24-re7-feedback' into 'develop'
fix: 🐛 resolve various bugs after re7 review

See merge request ldc-web/projets/ovoteam!19
2026-05-28 09:55:37 +00:00
Alexis Gousseau
c4fd2b048e
fix: 🐛 change default mailer to smtp 2026-05-27 11:07:56 +02:00
Corentin Huet
120db17596 fix: 🐛 resolve various bugs after re7 review 2026-05-26 14:52:59 +02:00
Noah Lancien
c747288704 Merge branch 'develop' into trunk 2026-05-04 17:38:56 +02:00
Noix
324e796fc6 Merge branch '24-content' into 'develop'
feat: ✨ update lorem ispum content with real content

See merge request ldc-web/projets/ovoteam!17
2026-05-04 15:37:48 +00:00
Corentin Huet
4512798e44 feat: ✨ update lorem ispum content with real content 2026-04-29 12:55:48 +02:00
karl.muller
379ffe003f Merge branch 'issue-17' into 'develop'
fix: 🐛 fixes after re7

See merge request ldc-web/projets/ovoteam!15
2026-04-21 10:11:52 +00:00
K Muller
fc472db322 fix: 🐛 fixes after re7 2026-04-21 10:54:38 +02:00
karl.muller
672af76d4b Merge branch 'fixes' into 'develop'
style: 💄 after re7 #17

See merge request ldc-web/projets/ovoteam!14
2026-04-16 07:11:58 +00:00
K Muller
ecae3660d0 style: 💄 after re7 #17 2026-04-15 09:31:53 +02:00
karl.muller
4eca9c7d24 Merge branch 'fix-session-driver' into 'develop'
fix: 🐛 add variable to manifest for session driver, default changed into the session.php

See merge request ldc-web/projets/ovoteam!13
2026-04-09 09:17:48 +00:00
K Muller
cdc7e86682 fix: 🐛 add variable to manifest for session driver, default changed into the session.php 2026-04-09 11:16:49 +02:00
karl.muller
cb9d7ac0d5 Merge branch 'addK8s' into 'develop'
feat: ✨ add k8s laravel package

See merge request ldc-web/projets/ovoteam!12
2026-04-09 08:34:39 +00:00
K Muller
4421d8e770 feat: ✨ add k8s laravel package 2026-04-09 10:31:17 +02:00
karl.muller
cc2c137b26 Merge branch '2026-fixes' into 'develop'
fix: 🐛 add seo title translated for each page

See merge request ldc-web/projets/ovoteam!11
2026-04-09 08:13:46 +00:00
K Muller
05af5cf83c fix: 🐛 add seo title translated for each page 2026-04-09 10:12:41 +02:00
karl.muller
083e89364b Merge branch 'fix-manifest' into 'develop'
fix: 🐛 missing middleware on manifest + missing favicon front

See merge request ldc-web/projets/ovoteam!10
2026-04-08 14:55:02 +00:00
K Muller
9aa84edcf3 fix: 🐛 missing middleware on manifest + missing favicon front 2026-04-08 16:51:52 +02:00
K Muller
76a42c17f6 fix: 🐛 error on Dockerfile + wrong favicon 2026-04-08 14:46:14 +02:00
K Muller
af13d298fd fix: 🐛 missing husky registry in Dockerfile 2026-04-08 14:27:09 +02:00
karl.muller
433edf13c8 Merge branch 'fix-linters' into 'ovoteam-2026'
fix: :style: 0 lint error

See merge request ldc-web/projets/ovoteam!9
2026-04-08 12:14:47 +00:00
K Muller
2f90c82d74 fix: :style: 0 lint error 2026-04-08 14:05:46 +02:00
karl.muller
6a82a59bc5 Merge branch 'karl' into 'ovoteam-2026'
feat: ✨ add :

See merge request ldc-web/projets/ovoteam!8
2026-04-07 14:22:07 +00:00
K Muller
db25bec3e5 feat: ✨ add :
- sentry
- variables in manifest
- some dynamic pages
- breadcrumbs
- dynamic page resolver
2026-04-07 16:21:09 +02:00
K Muller
f42dc37674 feat: ✨ add Recaptcha provider / form 2026-04-03 13:56:21 +02:00
K Muller
f2a4a367e0 feat: :construct: front pages + components 2026-04-02 17:29:11 +02:00
K Muller
a7d30e8353 feat: ✨ home page done 2026-03-30 14:29:49 +02:00
K Muller
9a404dd61a wip: on component 2026-03-30 14:29:00 +02:00
K Muller
32c6a9391d chore: 📦 install dynamic pages 2026-03-30 14:20:47 +02:00
K Muller
2dd2c78fa7 chore: 📦 install backend 2026-03-30 14:17:31 +02:00
K Muller
eef93adb2c chore: 📦 update laravel-common > 5.1.1 2026-03-30 14:12:01 +02:00
K Muller
a6370dcb47 first commit 2026-03-16 12:36:44 +01:00
672 changed files with 114011 additions and 2013 deletions

24
.editorconfig Normal file
View file

@ -0,0 +1,24 @@
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

75
.env.example Normal file
View file

@ -0,0 +1,75 @@
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=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=
MEILISEARCH_KEY=

4
.eslintignore Normal file
View file

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

80
.eslintrc.cjs Normal file
View file

@ -0,0 +1,80 @@
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 Normal file
View file

@ -0,0 +1,11 @@
* 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

28
.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
*.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
public/favicon-back
/docker-compose.yml

View file

@ -1,35 +1,221 @@
.docker-build:
stage: build
image:
name: gcr.io/kaniko-project/executor:v1.23.2-debug
entrypoint: [ "" ]
script:
- /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
stages:
- install
- lint
- test
- security
- build
- deploy
include:
- project: 'ldc-web/shared-ci'
ref: 'main'
file: 'deploy/deploy.yml'
- project: "ldc-web/shared-ci"
ref: "main"
file: "security/composer.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"
stages:
- build
- deploy
# * LINT --------------------------------------------------------------------
.php-lint-base: &php-lint-base
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

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

View file

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

2
.husky/commit-msg Executable file
View file

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

56
.stylelintrc.json Normal file
View file

@ -0,0 +1,56 @@
{
"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"
]
}
]
}
}

5
.vscode/extensions.json vendored Normal file
View file

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

109
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,109 @@
{
"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"
}

12
CHANGELOG.md Normal file
View file

@ -0,0 +1,12 @@
# 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

43
DOMAIN.md Normal file
View file

@ -0,0 +1,43 @@
## 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,4 +1,54 @@
FROM registry.s-web.fr/ldc-web/projets/ldc-web-images-docker/ldcweb-production-8.3:170625-101047
ARG PHP_8_5_TAG=latest
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 node:24-alpine AS frontend
ARG GITLAB_PASSWORD
RUN mkdir -p /app/public/build
COPY package*.json postcss.config.js tailwind.config.js tsconfig.json vite.config.ts .eslintrc.json .stylelintrc.json /app/
COPY resources/ /app/resources/
COPY --from=vendor /app/vendor/ /app/vendor/
WORKDIR /app
RUN npm config set @ldc-web:registry https://lab.s-web.fr/api/v4/packages/npm/
RUN npm config set -- '//lab.s-web.fr/api/v4/packages/npm/:_authToken' "${GITLAB_PASSWORD}"
RUN npm config set @uwil:registry https://lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/
RUN npm config set -- '//lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/:_authToken' "${GITLAB_PASSWORD}"
RUN npm install --no-audit --no-fund --loglevel=error && npm 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
COPY --chown=${USER}:${USER} . /app/srcs
USER ${USER}
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}

122
README.md
View file

@ -1,111 +1,33 @@
# OVO TEAM
# Table of contents
## Instalation
- [Table of contents](#table-of-contents)
- [Frameworks, Platforms and Libraries](#frameworks-platforms-and-libraries)
- [Documentation](#documentation)
- [Changelog](#changelog)
- [Copyright and License](#copyright-and-license)
Copier `.env` vers `.env.example` et remplir les informations.
# Frameworks, Platforms and Libraries
## Déployer
<!-- https://github.com/Ileriayo/markdown-badges -->
- Apres git pull sur l'envirronnement désiré
- Envoyer les éléments uploads si nécéssaire (Ne devrais pas étre le cas)
- Executer la partie `Compiler le front`
[![SASS](https://img.shields.io/badge/SASS-hotpink.svg?style=for-the-badge&logo=SASS&logoColor=white)](https://sass-lang.com/)
[![TailwindCSS](https://img.shields.io/badge/tailwindcss-%2338B2AC.svg?style=for-the-badge&logo=tailwind-css&logoColor=white)](https://tailwindcss.com/)
[![Vite](https://img.shields.io/badge/vite-%23646CFF.svg?style=for-the-badge&logo=vite&logoColor=white)](https://vite.dev/)
[![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)
## Faire une mise à jour
# Documentation
Commenter dans `public/.htaccess` la partie `# Hide update.php`, puis accéder à ce fichier via la navigateur
```php
composer i/npm i
```
## Fichier uploads
# Changelog
Au sein de `public/sites/default/files`
Latest version v1.0.0.
Les dossiers :
See the [CHANGELOG.md](CHANGELOG.md) file for details.
- `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 and License
## 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
Copyright (c) 2025 and beyond UWIL.

31103
_ide_helper.php Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,122 @@
<?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;
use InvalidArgumentException;
/** @implements CastsAttributes<NutritionalValues|null, NutritionalValues|array<string, mixed>|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) {
return null;
}
$data = is_string($value) ? json_decode($value, true) : $value;
if (! is_array($data)) {
return null;
}
return $this->fromArray($data);
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
if ($value === null) {
return [$key => null];
}
if (is_string($value)) {
$value = json_decode($value, true);
}
if (is_array($value)) {
$value = $this->fromArray($value);
}
if (! $value instanceof NutritionalValues) {
throw new InvalidArgumentException('The given value is not a NutritionalValues instance.');
}
return [$key => json_encode($value->toArray())];
}
/**
* @param array<array-key, mixed> $data
*/
private function fromArray(array $data): ?NutritionalValues
{
$refWeightData = Arr::array($data, 'refWeightG');
$rawVal = Arr::get($refWeightData, 'value');
$rawUnit = Arr::get($refWeightData, 'unit');
if ((! is_string($rawVal) && ! is_numeric($rawVal)) || ! is_string($rawUnit)) {
return null;
}
$refWeightUnit = NutritionnalUnit::tryFrom($rawUnit);
if ($refWeightUnit === null) {
return null;
}
/** @param array<string|int, mixed> $data */
$getProperty = function (array $data, string $key): ?NutritionalValue {
return when(
is_array(Arr::get($data, $key)),
fn () => $this->toNutritionalValues(Arr::array($data, $key)),
null
);
};
return new NutritionalValues(
refWeightG: new NutritionalValue((string) $rawVal, $refWeightUnit),
kj: $getProperty($data, 'kj'),
kcal: $getProperty($data, 'kcal'),
fatG: $getProperty($data, 'fatG'),
satFatG: $getProperty($data, 'satFatG'),
carbG: $getProperty($data, 'carbG'),
sugarG: $getProperty($data, 'sugarG'),
fiberG: $getProperty($data, 'fiberG'),
proteinG: $getProperty($data, 'proteinG'),
saltG: $getProperty($data, 'saltG'),
);
}
/**
* @param array<string|int, mixed> $data
*/
private function toNutritionalValues(array $data): ?NutritionalValue
{
$rawItemVal = Arr::get($data, 'value');
$rawItemUnit = Arr::get($data, 'unit');
if ((! is_string($rawItemVal) && ! is_numeric($rawItemVal)) || ! is_string($rawItemUnit)) {
return null;
}
$value = mb_trim((string) $rawItemVal);
$unit = NutritionnalUnit::tryFrom($rawItemUnit);
if ($value === '' || $unit === null) {
return null;
}
return new NutritionalValue($value, $unit);
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Contracts\Directories;
use App\Contracts\Directory;
use App\Dtos\CategoryProducts;
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, \App\Models\Product> */
interface ProductDirectory extends Directory
{
public function find(ProductId $id): ?Product;
public function findBySlug(string $slug): ?Product;
/**
* @return \App\Dtos\ProductSummary[]
*/
public function getAllProductsSummary(): 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
): ?CategoryProducts;
/** @return \Domain\Models\Entities\Product[] */
public function getRelatedProducts(RecipeId $recipeId, int $limit = 4): array;
}

View file

@ -0,0 +1,39 @@
<?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, \App\Models\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

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

View file

@ -0,0 +1,17 @@
<?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

@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace App\Directories;
use App\Contracts\Directories\ProductDirectory;
use App\Dtos\CategoryProducts;
use App\Dtos\ProductSummary;
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 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 getAllProductsSummary(): 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
): ?CategoryProducts {
$dbQueryCallback = function (Builder $query) use ($category, $breedingMethods, $technologies, $businesses, $isEgalim) {
/** @var \Illuminate\Database\Eloquent\Builder<Product> $query */
$query->where('category', $category->value)
->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 CategoryProducts(
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,
);
}
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,
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,
pictureAlt: $product->picture_alt,
pictureTitle: $product->picture_title,
pdfFilePath: $product->pdf,
associatedRecipes: $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()
->with(['recipes'])
->limit($limit)
->get()
->map(fn (Product $product) => $this->mapToEntity($product))
->all();
}
}

View file

@ -0,0 +1,141 @@
<?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']);
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,
picture: $recipe->picture,
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()
->with(['products'])
->limit($limit)
->get()
->map(fn (Recipe $recipe) => $this->mapToEntity($recipe))
->all();
}
}

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Dtos;
use Domain\Models\Entities\Product;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Illuminate\Support\Arr;
use Override;
final readonly class CategoryProducts 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
{
return array_merge(
Arr::wrap(json_decode(json_encode($product) ?: '', true)),
[
'id' => (string) $product->id->value,
]
);
}
}

View file

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

View file

@ -0,0 +1,33 @@
<?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

@ -0,0 +1,33 @@
<?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

@ -0,0 +1,13 @@
<?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

@ -0,0 +1,18 @@
<?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

@ -0,0 +1,13 @@
<?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));
}
}

View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Back;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\Session\Session;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Request;
use Illuminate\Routing\Route;
use Illuminate\Support\Str;
use Uwil\LaravelBackend\Controllers\BackendController as UwilBackendController;
use Uwil\LaravelBackend\Enums\ItemsPerPaginationEnum;
use Uwil\LaravelCommon\Helpers\ToolboxHelper;
class BackendController extends UwilBackendController
{
use AuthorizesRequests;
use ValidatesRequests;
public function __construct(
protected readonly Request $request,
protected readonly Session $session,
) {
}
/**
* Customize pagination with cache or config (default).
*
* @template T of \Illuminate\Database\Eloquent\Model
*
* @param \Illuminate\Database\Eloquent\Builder<T> $query
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator<array-key,T>
*/
protected function paginate(
Builder $query,
?ItemsPerPaginationEnum $pagination = null
): LengthAwarePaginator {
$route = $this->request->route();
throw_if(
$route === null,
\UnexpectedValueException::class,
'Route is required in order to paginate'
);
$cacheKey = $this->getCacheKey($route);
$perPage = $this->getPerPage($cacheKey);
$this->persistPerPage($cacheKey, $perPage);
return $query->paginate(perPage: $perPage)->withQueryString();
}
private function persistPerPage(string $cacheKey, int $perPage): void
{
if ($this->session->get($cacheKey) !== $perPage) {
$this->session->put($cacheKey, $perPage);
}
}
private function getPerPage(string $cacheKey): int
{
$perPage = $this->session->get($cacheKey);
$perPage = $perPage ?? ItemsPerPaginationEnum::twelve->value();
if (! is_scalar($perPage)) {
throw new \UnexpectedValueException('perPage shall be a scalar valuye');
}
return ToolboxHelper::getValidatedEnum(
intval($perPage),
'pagination',
'\Uwil\LaravelBackend\Enums\ItemsPerPaginationEnum',
);
}
private function getCacheKey(Route $route): string
{
$currentRoutePath = Str::slug($route->getName() ?? 'unknown');
return "pagination.{$currentRoutePath}";
}
}

View file

@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Back;
use App\Http\Requests\Back\Products\IndexProductRequest;
use App\Http\Requests\Back\Products\StoreProductRequest;
use App\Http\Requests\Back\Products\UpdateProductRequest;
use App\Http\Resources\CommonResource;
use App\Models\Product;
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 Illuminate\Contracts\Session\Session;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Response;
class ProductController extends BackendController
{
public function __construct(
Request $request,
Session $session,
) {
parent::__construct(
$request,
$session
);
$this->modelName = Product::modelName();
$this->modelPage = Product::modelPage();
}
public function index(IndexProductRequest $request): Response
{
$this->authorize('viewAny', Product::class);
/** @var \Illuminate\Database\Eloquent\Builder<\App\Models\Product> $query */
$query = Product::query();
if ($request->routeIs('back.products.trash')) {
$query = $query->onlyTrashed();
}
$searchValue = $request->getSearchValue();
if ($searchValue) {
$this->searchQuery(
$query,
$searchValue,
null,
'name'
);
}
$searchFields = \implode(', ', [
trans('validation.attributes.name'),
]);
$this->sortQuery($query);
$paginatedModels = $this->paginate($query);
return $this->inertiaRender('back/Pages/Products/Index', [
'paginatedModels' => CommonResource::collection($paginatedModels),
'search' => $searchValue,
'searchFields' => $searchFields,
'enumProductCategory' => ProductCategory::toArray(),
'enumTechnology' => Technology::toArray(),
]);
}
public function show(Product $product): RedirectResponse
{
$this->authorize('viewAny', Product::class);
return redirect()->route('back.products.edit', $product);
}
public function create(Product $product): Response
{
$this->authorize('create', Product::class);
return $this->inertiaRender('back/Pages/Products/Create', [
'data' => CommonResource::make($product),
'enumProductCategory' => ProductCategory::toArray(),
'enumTechnology' => Technology::toArray(),
'enumBreedingMethod' => BreedingMethod::toArray(),
'enumBusiness' => Business::toArray(),
'enumProductFormat' => ProductFormat::toArray(),
'enumNutritionnalUnit' => NutritionnalUnit::toArray(),
]);
}
public function store(StoreProductRequest $request): RedirectResponse
{
$this->authorize('create', Product::class);
return DB::transaction(function () use ($request) {
$product = new Product();
/** @var array<string, mixed> $data */
$data = $request->only($product->getFillable());
$product->fill($data);
if ($product->saveOrFail()) {
return redirect()->route('back.products.edit', $product)
->with('success', trans('uwil-backend::crud.messages.has_been_created', [
'model' => Str::ucfirst(trans_choice('models.classes.product', 1)),
]));
}
return redirect()->back()
->with('error', trans('uwil-backend::crud.messages.cannot_be_created', [
'model' => Str::ucfirst(trans_choice('models.classes.product', 1)),
]));
});
}
public function edit(Product $product): Response
{
$this->authorize('update', $product);
return $this->inertiaRender('back/Pages/Products/Edit', [
'data' => CommonResource::make($product),
'enumProductCategory' => ProductCategory::toArray(),
'enumTechnology' => Technology::toArray(),
'enumBreedingMethod' => BreedingMethod::toArray(),
'enumBusiness' => Business::toArray(),
'enumProductFormat' => ProductFormat::toArray(),
'enumNutritionnalUnit' => NutritionnalUnit::toArray(),
]);
}
public function update(
UpdateProductRequest $request,
Product $product
): RedirectResponse {
$this->authorize('update', $product);
return DB::transaction(function () use ($request, $product) {
/** @var array<string, mixed> $data */
$data = $request->only($product->getFillable());
$product->fill($data);
if ($product->saveOrFail()) {
return redirect()->route('back.products.edit', $product)
->with('success', trans('uwil-backend::crud.messages.has_been_updated', [
'model' => Str::ucfirst(trans_choice('models.classes.product', 1)),
]));
}
return redirect()->route('back.products.edit', $product)
->with('error', trans('uwil-backend::crud.messages.cannot_be_updated', [
'model' => Str::ucfirst(trans_choice('models.classes.product', 1)),
]));
});
}
public function duplicate(Product $product): Response
{
return $this->create($product->replicate());
}
public function restore(string $id): RedirectResponse
{
$product = Product::withTrashed()->findOrFail($id);
$this->authorize('restore', $product);
$previousQueries = $this->getPreviousQueries();
if ($product->restore()) {
return redirect()->route('back.products.trash', $previousQueries)
->with('success', trans('uwil-backend::crud.messages.has_been_restored', [
'model' => Str::ucfirst(trans_choice('models.classes.product', 1)),
]));
}
return redirect()->back()
->with('error', trans('uwil-backend::crud.messages.cannot_be_restored', [
'model' => Str::ucfirst(trans_choice('models.classes.product', 1)),
]));
}
public function destroy(Product $product): RedirectResponse
{
$this->authorize('delete', $product);
$previousQueries = $this->getPreviousQueries();
if ($product->delete()) {
return redirect()->route('back.products.index', $previousQueries)
->with('success', trans('uwil-backend::crud.messages.has_been_deleted', [
'model' => Str::ucfirst(trans_choice('models.classes.product', 1)),
]));
}
return redirect()->back()
->with('error', trans('uwil-backend::crud.messages.cannot_be_deleted', [
'model' => Str::ucfirst(trans_choice('models.classes.product', 1)),
]));
}
public function forceDestroy(string $id): RedirectResponse
{
$product = Product::withTrashed()->findOrFail($id);
$this->authorize('forceDelete', $product);
$previousQueries = $this->getPreviousQueries();
if ($product->forceDelete()) {
return redirect()->route('back.products.trash', $previousQueries)
->with('success', trans('uwil-backend::crud.messages.has_been_deleted', [
'model' => Str::ucfirst(trans_choice('models.classes.product', 1)),
]));
}
return redirect()->back()
->with('error', trans('uwil-backend::crud.messages.cannot_be_deleted', [
'model' => Str::ucfirst(trans_choice('models.classes.product', 1)),
]));
}
/** @return array<array-key,string|array<mixed>> */
private function getPreviousQueries(): array
{
$previousQueries = [];
$previousQueryString = \parse_url($this->getPreviousUrl(), \PHP_URL_QUERY) ?? '' ?: '';
\parse_str($previousQueryString, $previousQueries);
return $previousQueries;
}
private function getPreviousUrl(): string
{
return url()->previous(route('back.dashboard.index'));
}
}

View file

@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Back;
use App\Contracts\Directories\ProductDirectory;
use App\Http\Requests\Back\Recipes\IndexRecipeRequest;
use App\Http\Requests\Back\Recipes\StoreRecipeRequest;
use App\Http\Requests\Back\Recipes\UpdateRecipeRequest;
use App\Http\Resources\CommonResource;
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\Contracts\Session\Session;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Response;
class RecipeController extends BackendController
{
public function __construct(
Request $request,
Session $session,
private readonly ProductDirectory $productDirectory,
) {
parent::__construct(
$request,
$session
);
$this->modelName = Recipe::modelName();
$this->modelPage = Recipe::modelPage();
}
public function index(IndexRecipeRequest $request): Response
{
$this->authorize('viewAny', Recipe::class);
/** @var \Illuminate\Database\Eloquent\Builder<\App\Models\Recipe> $query */
$query = Recipe::query();
if ($request->routeIs('back.recipes.trash')) {
$query = $query->onlyTrashed();
}
$searchValue = $request->getSearchValue();
if ($searchValue) {
$this->searchQuery(
$query,
$searchValue,
null,
'name'
);
}
$searchFields = \implode(', ', [
trans('validation.attributes.name'),
]);
$this->sortQuery($query);
$paginatedModels = $this->paginate($query);
return $this->inertiaRender('back/Pages/Recipes/Index', [
'paginatedModels' => CommonResource::collection($paginatedModels),
'search' => $searchValue,
'searchFields' => $searchFields,
'enumProductCategory' => ProductCategory::toArray(),
'enumConsumptionTime' => ConsumptionTime::toArray(),
'enumInspiration' => Inspiration::toArray(),
'enumSeason' => Season::toArray(),
]);
}
public function show(Recipe $recipe): RedirectResponse
{
$this->authorize('viewAny', Recipe::class);
return redirect()->route('back.recipes.edit', $recipe);
}
public function create(Recipe $recipe): Response
{
$this->authorize('create', Recipe::class);
return $this->inertiaRender('back/Pages/Recipes/Create', [
'data' => CommonResource::make($recipe),
'enumProductCategory' => ProductCategory::toArray(),
'enumConsumptionTime' => ConsumptionTime::toArray(),
'enumInspiration' => Inspiration::toArray(),
'enumSeason' => Season::toArray(),
'allProducts' => $this->productDirectory->getAllProductsSummary(),
]);
}
public function store(StoreRecipeRequest $request): RedirectResponse
{
$this->authorize('create', Recipe::class);
return DB::transaction(function () use ($request) {
$recipe = new Recipe();
/** @var array<string, mixed> $data */
$data = $request->only($recipe->getFillable());
$recipe->fill($data);
if ($recipe->saveOrFail()) {
$recipe->products()
->sync($request->array('products'));
return redirect()->route('back.recipes.edit', $recipe)
->with('success', trans('uwil-backend::crud.messages.has_been_created', [
'model' => Str::ucfirst(trans_choice('models.classes.recipes', 1)),
]));
}
return redirect()->back()
->with('error', trans('uwil-backend::crud.messages.cannot_be_created', [
'model' => Str::ucfirst(trans_choice('models.classes.recipe', 1)),
]));
});
}
public function edit(Recipe $recipe): Response
{
$this->authorize('update', $recipe);
$recipe->load('products');
return $this->inertiaRender('back/Pages/Recipes/Edit', [
'data' => CommonResource::make($recipe),
'enumProductCategory' => ProductCategory::toArray(),
'enumConsumptionTime' => ConsumptionTime::toArray(),
'enumInspiration' => Inspiration::toArray(),
'enumSeason' => Season::toArray(),
'allProducts' => $this->productDirectory->getAllProductsSummary(),
]);
}
public function update(
UpdateRecipeRequest $request,
Recipe $recipe
): RedirectResponse {
$this->authorize('update', $recipe);
return DB::transaction(function () use ($request, $recipe) {
/** @var array<string, mixed> $data */
$data = $request->only($recipe->getFillable());
$recipe->fill($data);
if ($recipe->saveOrFail()) {
$recipe->products()
->sync($request->array('products'));
return redirect()->route('back.recipes.edit', $recipe)
->with('success', trans('uwil-backend::crud.messages.has_been_updated', [
'model' => Str::ucfirst(trans_choice('models.classes.recipe', 1)),
]));
}
return redirect()->route('back.recipes.edit', $recipe)
->with('error', trans('uwil-backend::crud.messages.cannot_be_updated', [
'model' => Str::ucfirst(trans_choice('models.classes.recipe', 1)),
]));
});
}
public function duplicate(Recipe $recipe): Response
{
$this->authorize('duplicate', $recipe);
return $this->create($recipe->replicate());
}
public function restore(string $id): RedirectResponse
{
$recipe = Recipe::withTrashed()->findOrFail($id);
$this->authorize('restore', $recipe);
$previousQueries = $this->getPreviousQueries();
if ($recipe->restore()) {
return redirect()->route('back.recipes.trash', $previousQueries)
->with('success', trans('uwil-backend::crud.messages.has_been_restored', [
'model' => Str::ucfirst(trans_choice('models.classes.recipe', 1)),
]));
}
return redirect()->back()
->with('error', trans('uwil-backend::crud.messages.cannot_be_restored', [
'model' => Str::ucfirst(trans_choice('models.classes.recipe', 1)),
]));
}
public function destroy(Recipe $recipe): RedirectResponse
{
$this->authorize('delete', $recipe);
$previousQueries = $this->getPreviousQueries();
if ($recipe->delete()) {
return redirect()->route('back.recipes.index', $previousQueries)
->with('success', trans('uwil-backend::crud.messages.has_been_deleted', [
'model' => Str::ucfirst(trans_choice('models.classes.recipe', 1)),
]));
}
return redirect()->back()
->with('error', trans('uwil-backend::crud.messages.cannot_be_deleted', [
'model' => Str::ucfirst(trans_choice('models.classes.recipe', 1)),
]));
}
public function forceDestroy(string $id): RedirectResponse
{
$recipe = Recipe::withTrashed()->findOrFail($id);
$this->authorize('forceDelete', $recipe);
$previousQueries = $this->getPreviousQueries();
if ($recipe->forceDelete()) {
return redirect()->route('back.recipes.trash', $previousQueries)
->with('success', trans('uwil-backend::crud.messages.has_been_deleted', [
'model' => Str::ucfirst(trans_choice('models.classes.recipe', 1)),
]));
}
return redirect()->back()
->with('error', trans('uwil-backend::crud.messages.cannot_be_deleted', [
'model' => Str::ucfirst(trans_choice('models.classes.recipe', 1)),
]));
}
/** @return array<array-key,string|array<mixed>> */
private function getPreviousQueries(): array
{
$previousQueries = [];
$previousQueryString = \parse_url($this->getPreviousUrl(), \PHP_URL_QUERY) ?? '' ?: '';
\parse_str($previousQueryString, $previousQueries);
return $previousQueries;
}
private function getPreviousUrl(): string
{
return url()->previous(route('back.dashboard.index'));
}
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
use AuthorizesRequests;
use DispatchesJobs;
use ValidatesRequests;
}

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Front;
use App\Contracts\Directories\ProductDirectory;
use App\Contracts\Directories\RecipeDirectory;
use App\Http\Controllers\Controller;
use App\Http\Requests\Front\Products\IndexProductRequest;
use Domain\Models\Entities\Product;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Inertia\Inertia;
use Inertia\Response;
class ProductController extends Controller
{
public function __construct(
private readonly ProductDirectory $productDirectory,
private readonly RecipeDirectory $recipeDirectory,
) {
}
public function index(IndexProductRequest $request): Response
{
$categories = $request->getCategories();
$breedingMethods = $request->getBreedingMethods();
$technologies = $request->getTechnologies();
$businesses = $request->getBusinesses();
$isEgalim = $request->getIsEgalim();
$search = $request->getSearchValue() ?? '';
$categoriesProducts = [];
if (count($categories) === 0) {
$categories = ! $request->hasFilter() ? [
ProductCategory::OMELETS_AND_DELI_AND_DESSERT_LINES,
ProductCategory::HARD_BOILED_EGGS,
] : ProductCategory::cases();
}
foreach ($categories as $category) {
$result = $this->productDirectory->getCategoryProducts(
category: $category,
breedingMethods: $breedingMethods,
technologies: $technologies,
businesses: $businesses,
isEgalim: $isEgalim,
search: $search,
// * No limit on search is volontary, limit is done in front, the creative team did not mind the pagination work.
limitPerCategory: -1
);
if ($result !== null) {
$categoriesProducts[] = $result;
}
}
return Inertia::render('front/Pages/Products/Index', [
'categoriesProducts' => $categoriesProducts,
]);
}
public function show(Product $product): Response
{
$relatedRecipes = $this->recipeDirectory
->getRelatedRecipes($product->id(), 10);
return Inertia::render('front/Pages/Products/Show', [
'product' => $product,
'relatedRecipes' => $relatedRecipes,
]);
}
}

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Front;
use App\Contracts\Directories\ProductDirectory;
use App\Contracts\Directories\RecipeDirectory;
use App\Http\Controllers\Controller;
use App\Http\Requests\Front\Recipes\IndexRecipeRequest;
use Domain\Models\Entities\Recipe;
use Domain\Models\ValueObjects\Pagination;
use Illuminate\Pagination\LengthAwarePaginator;
use Inertia\Inertia;
use Inertia\Response;
class RecipeController extends Controller
{
public function __construct(
private readonly RecipeDirectory $recipeDirectory,
private readonly ProductDirectory $productDirectory,
) {
}
public function index(IndexRecipeRequest $request): Response
{
$categories = $request->getCategories();
$consumptionTimes = $request->getConsumptionTimes();
$inspirations = $request->getInspirations();
$seasons = $request->getSeasons();
$search = $request->getSearchValue() ?? '';
// * Load all recipes for client-side display
$allRecipes = [];
$page = 1;
$perPage = 100;
do {
$pagination = Pagination::ofPage($page, $perPage);
$paginatedRecipes = $this->recipeDirectory->getPaginatedRecipes(
pagination: $pagination,
productRanges: $categories,
consumptionTimes: $consumptionTimes,
inspirations: $inspirations,
seasons: $seasons,
search: $search
);
foreach ($paginatedRecipes->items() as $recipe) {
$allRecipes[] = $recipe;
}
$page++;
} while ($page <= $paginatedRecipes->lastPage());
$mockedPaginatedRecipes = new LengthAwarePaginator(
items: $allRecipes,
total: count($allRecipes),
perPage: max(1, count($allRecipes)),
currentPage: 1,
);
return Inertia::render('front/Pages/Recipes/Index', [
'paginatedRecipes' => $mockedPaginatedRecipes,
]);
}
public function show(Recipe $recipe): Response
{
$relatedProducts = $this->productDirectory
->getRelatedProducts($recipe->id(), 10);
return Inertia::render('front/Pages/Recipes/Show', [
'recipe' => $recipe,
'relatedProducts' => $relatedProducts,
]);
}
}

View file

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Http\Middlewares;
use App\Lib\Helpers\ToolboxHelper;
use Illuminate\Http\Request;
use Mews\Purifier\Purifier;
use Symfony\Component\HttpFoundation\Response;
class HtmlSanitizer
{
public function __construct(
private readonly Purifier $purifierService
) {
}
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request):(\Illuminate\Http\Response) $next
* @param string ...$params List of input names that should be sanitized with rich sanitization (allowing some HTML)
*/
public function handle(Request $request, \Closure $next, string ...$params): Response
{
$inputs = $request->all();
\array_walk($inputs, function (mixed &$input, int | string $name) use ($params): void {
$this->cleanInput($input, (string) $name, null, $params);
});
$request->merge($inputs);
return $next($request);
}
/**
* @param string[] $params
*/
private function cleanInput(mixed &$input, string $name, ?string $rootName, array $params): void
{
if (is_array($input)) {
array_walk_recursive($input, function (mixed &$nestedInput, int | string $nestedName) use ($name, $params): void {
$this->cleanInput($nestedInput, (string) $nestedName, $name, $params);
});
}
if (! is_string($input)) {
return;
}
if ($this->shouldApplyRichSanitization($name, $rootName, $params)) {
$this->sanitizeRichInput($input);
return;
}
$input = strip_tags($input);
}
/**
* @param string[] $params
*/
private function shouldApplyRichSanitization(string $name, ?string $rootName, array $params): bool
{
return in_array($name, $params) || in_array($rootName ?? $name, $params);
}
private function sanitizeRichInput(string &$input): void
{
$isJson = \json_decode($input, true);
if ($isJson && is_array($isJson)) {
\array_walk_recursive($isJson, function (string | array &$item): void {
if (\is_string($item)) {
$item = $this->purifyString($item);
}
});
$input = json_encode($isJson, \JSON_THROW_ON_ERROR) ?: throw new \UnexpectedValueException('Failed to encode JSON after sanitization.');
return;
}
$input = $this->purifyString($input);
}
private function purifyString(string $input): string
{
$cleaned = $this->purifierService->clean($input);
if (! is_string($cleaned)) {
throw new \UnexpectedValueException('Purifier should return a string when input is a string.');
}
return ToolboxHelper::stripCkEditorSchemesOnMediaInputs($cleaned);
}
}

View file

@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace App\Http\Middlewares;
use Closure;
use Illuminate\Contracts\Container\Container;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Vite;
use Symfony\Component\HttpFoundation\Response;
class SecureHeaders
{
/**
* Enumerate headers which you do not want in your application's responses.
* Great starting point would be to go check out @Scott_Helme's:
* https://securityheaders.com/
*
* @var string[]
*/
private $unwantedHeaderList = [
'X-Powered-By',
'Server',
];
public function __construct(
private readonly Container $container
) {
}
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request):(\Illuminate\Http\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$appUrl = Config::string('app.url');
$appDebug = Config::boolean('app.debug');
$telescopeEnabled = Config::boolean('telescope.enabled', false);
// * For Vite DEV
$host = \parse_url($appUrl, \PHP_URL_HOST);
$viteDevOrigin = $appDebug && \is_string($host) && $host !== ''
? "https://{$host}:4443"
: '';
$viteDevSocket = $appDebug && \is_string($host) && $host !== ''
? "wss://{$host}:4443"
: '';
$nonce = Vite::cspNonce();
$jsBo = $request->routeIs('bo*') ? "'unsafe-eval'" : '';
$jsdelivrUrl = 'https://cdn.jsdelivr.net';
$openStreetMapTiles = 'https://*.tile.openstreetmap.org';
$this->declareNonce($nonce);
$this->removeUnwantedHeaders($this->unwantedHeaderList);
$response = $next($request);
$response->headers->set('Referrer-Policy', 'no-referrer-when-downgrade');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
// * Ignore CSP on some conditions.
if (
$appDebug && (
$response->isClientError() || // Ignition
$response->isServerError() || // Ignition
Vite::isRunningHot() || // Vite
($telescopeEnabled && str_starts_with($request->path(), 'telescope')) // Telescope
)
) {
return $response;
}
$cspPolicies = [
"default-src 'none';",
"base-uri 'self';",
"manifest-src 'self';",
"media-src 'self' blob: {$appUrl};",
// * CKEDITOR Specific https://ckeditor.com/docs/ckeditor5/latest/installation/advanced/csp.html
"connect-src 'self' blob: " . implode(' ', [
'https://yl.s-web.fr/',
// * BAN address autocomplete (api-adresse.data.gouv.fr)
'https://api-adresse.data.gouv.fr',
'https://maps.googleapis.com',
// * Google reCAPTCHA
'https://www.google.com/recaptcha/',
'https://recaptcha.google.com/recaptcha/',
'https://www.google.com',
'https://www.recaptcha.net/',
// * VITE DEV
$viteDevSocket,
$viteDevOrigin,
]) . ';',
"img-src 'self' data: blob: " . implode(' ', [
'https://maps.googleapis.com',
'https://maps.gstatic.com',
$viteDevOrigin,
$openStreetMapTiles,
]) . ' data:;',
'child-src ' . implode(' ', []) . ';',
"frame-src 'self' " . implode(' ', [
'https://www.google.com/recaptcha/',
'https://recaptcha.google.com/recaptcha/',
'https://www.google.com/maps/',
'https://www.youtube.com/embed/',
'https://player.vimeo.com/',
'https://e.issuu.com/embed.html?',
]) . ';',
"object-src 'none';",
"font-src 'self' data: " . \implode(' ', [
'https://fonts.gstatic.com',
// * VITE DEV
$viteDevOrigin,
]) . ';',
// ! Using CKEditor will must have unsafe-inline style
"style-src 'self' 'unsafe-inline' " . \implode(' ', [
$jsdelivrUrl,
'https://cdnjs.cloudflare.com',
'https://fonts.googleapis.com',
'https://use.fontawesome.com',
// * File Manager
// * VITE DEV
$viteDevOrigin,
]) . ';',
// ! If you dont use ckeditor use this
// "style-src 'self' 'nonce-$nonce';",
"script-src 'self' 'nonce-$nonce' 'strict-dynamic' " . \implode(' ', [
'https: blob:',
$jsBo,
'https://www.googletagmanager.com',
'https://cdnjs.cloudflare.com',
'https://www.google-analytics.com',
// * VITE DEV
$viteDevOrigin,
]) . ';',
];
$response->headers->set(
'Content-Security-Policy',
implode(' ', $cspPolicies)
);
$response->headers->set('Feature-Policy', 'fullscreen \'self\'; geolocation \'self\';');
return $response;
}
/**
* Remove unwanted headers
*
* @param string[] $headerList List of headers to remove from the response
* @return void
*/
private function removeUnwantedHeaders(array $headerList)
{
foreach ($headerList as $header) {
header_remove($header);
}
}
private function declareNonce(?string $value): void
{
Vite::useCspNonce($value);
$this->container->singleton('csp_nonce', $value);
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Back\Products;
use App\Services\AdminAuthService;
use Illuminate\Foundation\Http\FormRequest;
class IndexProductRequest extends FormRequest
{
public function __construct(
private readonly AdminAuthService $authService
) {
}
public function authorize(): bool
{
return $this->authService->check();
}
public function getSearchValue(): ?string
{
return $this->has('search') ?
$this->string('search')->value() : null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Validation\Rule|array<mixed>|string>
*/
public function rules(): array
{
return [
'search' => ['sometimes', 'string'],
];
}
}

View file

@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Back\Products;
use App\Services\AdminAuthService;
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 Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;
class StoreProductRequest extends FormRequest
{
protected const int MAX_FILE_SIZE_KB = 5120;
protected const int PICTURE_WIDTH = 800;
protected const int PICTURE_HEIGHT = 800;
public function __construct(
private readonly AdminAuthService $authService
) {
}
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->authService->check();
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Validation\Rule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'seo_title' => ['required', 'string', 'between:45,70'],
'seo_description' => ['required', 'string', 'between:50,160'],
'category' => ['required', new Enum(ProductCategory::class)],
'technology' => ['required', new Enum(Technology::class)],
'is_egalim' => ['boolean'],
'breeding_methods' => ['required', 'array'],
'breeding_methods.*' => ['required', new Enum(BreedingMethod::class)],
'businesses' => ['required', 'array'],
'businesses.*' => ['required', new Enum(Business::class)],
'formats' => ['required', 'array'],
'formats.*' => ['required', new Enum(ProductFormat::class)],
'description' => ['required', 'string', 'min:15', 'max:500'],
'details_and_advantages' => ['required', 'string'],
'handling_advices' => ['required', 'string'],
'ingredients' => ['nullable', 'string'],
'conservation_before_opening' => ['required', 'string', 'max:255'],
'conservation_after_opening' => ['nullable', 'string', 'max:255'],
'picture' => [
'required',
Rule::imageFile()
->max(self::MAX_FILE_SIZE_KB)
->dimensions(
Rule::dimensions()
->width(self::PICTURE_WIDTH)
->height(self::PICTURE_HEIGHT)
),
],
'picture_alt' => ['required', 'string', 'min:4', 'max:75'],
'picture_title' => ['required', 'string', 'min:4', 'max:75'],
'pdf' => [
'nullable',
Rule::file()
->max(self::MAX_FILE_SIZE_KB)
->types('pdf'),
],
'nutritional_values' => ['nullable', 'array'],
'nutritional_values.*.value' => ['required_with:nutritional_values.*.unit', 'string', 'min:1', 'max:50'],
'nutritional_values.*.unit' => ['required_with:nutritional_values.*.value', new Enum(NutritionnalUnit::class)],
];
}
/**
* Get custom attributes for validator errors.
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'name' => trans('validation.attributes.name'),
'seo_title' => trans('validation.attributes.seo_title'),
'seo_description' => trans('validation.attributes.seo_description'),
'category' => trans('validation.attributes.category'),
'technology' => trans('validation.attributes.technology'),
'is_egalim' => trans('validation.attributes.is_egalim'),
'breeding_methods' => trans('validation.attributes.breeding_methods'),
'breeding_methods.*' => trans('validation.attributes.breeding_methods'),
'businesses' => trans('validation.attributes.businesses'),
'businesses.*' => trans('validation.attributes.businesses'),
'formats' => trans('validation.attributes.formats'),
'formats.*' => trans('validation.attributes.formats'),
'description' => trans('validation.attributes.description'),
'details_and_advantages' => trans('validation.attributes.details_and_advantages'),
'handling_advices' => trans('validation.attributes.handling_advices'),
'ingredients' => trans('validation.attributes.ingredients'),
'conservation_before_opening' => trans('validation.attributes.conservation_before_opening'),
'conservation_after_opening' => trans('validation.attributes.conservation_after_opening'),
'picture' => trans('validation.attributes.picture'),
'picture_alt' => trans('validation.attributes.picture_alt'),
'picture_title' => trans('validation.attributes.picture_title'),
'pdf' => trans('validation.attributes.pdf'),
'nutritional_values' => trans('validation.attributes.nutritional_values'),
'nutritional_values.refWeightG.value' => trans('validation.attributes.nutritional_refWeightG'),
'nutritional_values.kj.value' => trans('validation.attributes.nutritional_kj'),
'nutritional_values.kcal.value' => trans('validation.attributes.nutritional_kcal'),
'nutritional_values.fatG.value' => trans('validation.attributes.nutritional_fatG'),
'nutritional_values.satFatG.value' => trans('validation.attributes.nutritional_satFatG'),
'nutritional_values.carbG.value' => trans('validation.attributes.nutritional_carbG'),
'nutritional_values.sugarG.value' => trans('validation.attributes.nutritional_sugarG'),
'nutritional_values.fiberG.value' => trans('validation.attributes.nutritional_fiberG'),
'nutritional_values.proteinG.value' => trans('validation.attributes.nutritional_proteinG'),
'nutritional_values.saltG.value' => trans('validation.attributes.nutritional_saltG'),
'nutritional_values.*.unit' => trans('validation.attributes.nutritional_values_unit'),
];
}
}

View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Back\Products;
use App\Lib\Helpers\ToolboxHelper;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
class UpdateProductRequest extends StoreProductRequest
{
public function rules(): array
{
return array_merge(parent::rules(), [
'pdf' => [
'nullable',
$this->validatePdf(...),
],
'picture' => [
'required',
$this->validatePicture(...),
],
]);
}
private function validatePdf(string $attribute, mixed $value, \Closure $fail): void
{
$valuePath = match (true) {
is_string($value) => $value,
$value instanceof UploadedFile => $value->getPathname(),
default => null
};
if (is_string($valuePath)) {
$this->validateMaxFileSize($attribute, $valuePath, $fail);
ToolboxHelper::validatePdf(
$attribute,
$valuePath,
$fail
);
} elseif ($value !== null) {
$fail('Unexpected value for pdf');
}
}
private function validatePicture(string $attribute, mixed $value, \Closure $fail): void
{
$valuePath = match (true) {
is_string($value) => $value,
$value instanceof UploadedFile => $value->getPathname(),
default => null
};
if (is_string($valuePath)) {
$this->validateMaxFileSize($attribute, $valuePath, $fail);
ToolboxHelper::validatePicture(
$attribute,
$valuePath,
$fail,
(object) [
'minWidth' => self::PICTURE_WIDTH,
'maxWidth' => self::PICTURE_WIDTH,
'minHeight' => self::PICTURE_HEIGHT,
'maxHeight' => self::PICTURE_HEIGHT,
]
);
} elseif ($value !== null) {
$fail('Unexpected value for picture');
}
}
private function validateMaxFileSize(string $attribute, string $valuePath, \Closure $fail): void
{
$sizeInBytes = match (true) {
Storage::exists($valuePath) => Storage::size($valuePath),
File::exists($valuePath) => File::size($valuePath),
default => 0,
};
if (($sizeInBytes / 1024) > self::MAX_FILE_SIZE_KB) {
$fail(trans('validation.max.file', [
'attribute' => $this->attributes()[$attribute] ?? $attribute,
'max' => self::MAX_FILE_SIZE_KB,
]));
}
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Back\Recipes;
use App\Services\AdminAuthService;
use Illuminate\Foundation\Http\FormRequest;
class IndexRecipeRequest extends FormRequest
{
public function __construct(
private readonly AdminAuthService $authService
) {
}
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->authService->check();
}
public function getSearchValue(): ?string
{
return $this->has('search') ?
$this->string('search')->value() : null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Validation\Rule|array<mixed>|string>
*/
public function rules(): array
{
return [
'search' => ['sometimes', 'string'],
];
}
}

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Back\Recipes;
use App\Services\AdminAuthService;
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\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;
class StoreRecipeRequest extends FormRequest
{
protected const int MAX_FILE_SIZE_KB = 5120;
protected const int PICTURE_WIDTH = 800;
protected const int PICTURE_HEIGHT = 800;
public function __construct(
private readonly AdminAuthService $authService
) {
}
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->authService->check();
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Validation\Rule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'seo_title' => ['required', 'string', 'between:45,70'],
'seo_description' => ['required', 'string', 'between:50,160'],
'product_category' => ['required', new Enum(ProductCategory::class)],
'consomption_time' => ['required', new Enum(ConsumptionTime::class)],
'inspiration' => ['required', new Enum(Inspiration::class)],
'season' => ['required', new Enum(Season::class)],
'description' => ['nullable', 'string'],
'details' => ['nullable', 'string'],
'ingredients_list' => ['required', 'string'],
'preparation_steps' => ['required', 'string'],
'tips_and_advices' => ['nullable', 'string'],
'picture' => [
'required',
Rule::imageFile()
->max(self::MAX_FILE_SIZE_KB)
->dimensions(
Rule::dimensions()
->width(static::PICTURE_WIDTH)
->height(static::PICTURE_HEIGHT)
),
],
'picture_alt' => ['required', 'string', 'min:4', 'max:75'],
'picture_title' => ['required', 'string', 'min:4', 'max:75'],
'products' => ['nullable', 'array'],
'products.*' => ['string', 'uuid', 'exists:products,id'],
];
}
/**
* Get custom attributes for validator errors.
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'name' => trans('validation.attributes.name'),
'seo_title' => trans('validation.attributes.seo_title'),
'seo_description' => trans('validation.attributes.seo_description'),
'product_category' => trans('validation.attributes.product_category'),
'consomption_time' => trans('validation.attributes.consomption_time'),
'inspiration' => trans('validation.attributes.inspiration'),
'season' => trans('validation.attributes.season'),
'description' => trans('validation.attributes.description'),
'details' => trans('validation.attributes.details'),
'ingredients_list' => trans('validation.attributes.ingredients_list'),
'preparation_steps' => trans('validation.attributes.preparation_steps'),
'tips_and_advices' => trans('validation.attributes.tips_and_advices'),
'picture' => trans('validation.attributes.picture'),
'picture_alt' => trans('validation.attributes.picture_alt'),
'picture_title' => trans('validation.attributes.picture_title'),
'products' => trans('validation.attributes.associated_products'),
];
}
}

View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Back\Recipes;
use App\Lib\Helpers\ToolboxHelper;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
class UpdateRecipeRequest extends StoreRecipeRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Validation\Rule|array<mixed>|string>
*/
public function rules(): array
{
return array_merge(parent::rules(), [
'picture' => [
'required',
$this->validatePicture(...),
],
]);
}
private function validatePicture(string $attribute, mixed $value, \Closure $fail): void
{
$valuePath = match (true) {
is_string($value) => $value,
$value instanceof UploadedFile => $value->getPathname(),
default => null
};
if (is_string($valuePath)) {
$this->validateMaxFileSize($attribute, $valuePath, $fail);
ToolboxHelper::validatePicture(
$attribute,
$valuePath,
$fail,
(object) [
'minWidth' => self::PICTURE_WIDTH,
'maxWidth' => self::PICTURE_WIDTH,
'minHeight' => self::PICTURE_HEIGHT,
'maxHeight' => self::PICTURE_HEIGHT,
]
);
} elseif ($value !== null) {
$fail('Unexpected value for picture');
}
}
private function validateMaxFileSize(string $attribute, string $valuePath, \Closure $fail): void
{
$sizeInBytes = match (true) {
Storage::exists($valuePath) => Storage::size($valuePath),
File::exists($valuePath) => File::size($valuePath),
default => 0,
};
if (($sizeInBytes / 1024) > self::MAX_FILE_SIZE_KB) {
$fail(trans('validation.max.file', [
'attribute' => $this->attributes()[$attribute] ?? $attribute,
'max' => self::MAX_FILE_SIZE_KB,
]));
}
}
}

View file

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Front\Products;
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 Illuminate\Foundation\Http\FormRequest;
class IndexProductRequest extends FormRequest
{
/**
* @return \Domain\Models\ValueObjects\Products\ProductCategory[]
*/
public function getCategories(): array
{
return array_map(
function ($category) {
if (! is_string($category)) {
throw new \InvalidArgumentException('Category must be a string');
}
return ProductCategory::from($category);
},
$this->array('category'),
);
}
/** @return \Domain\Models\ValueObjects\Products\BreedingMethod[] */
public function getBreedingMethods(): array
{
return array_map(
function ($breeding) {
if (! is_string($breeding)) {
throw new \InvalidArgumentException('Breeding method must be a string');
}
return BreedingMethod::from($breeding);
},
$this->array('breeding'),
);
}
/** @return \Domain\Models\ValueObjects\Products\Technology[] */
public function getTechnologies(): array
{
return array_map(
function ($technology) {
if (! is_string($technology)) {
throw new \InvalidArgumentException('Technology must be a string');
}
return Technology::from($technology);
},
$this->array('technology'),
);
}
/** @return \Domain\Models\ValueObjects\Products\Business[] */
public function getBusinesses(): array
{
return array_map(
function ($business) {
if (! is_string($business)) {
throw new \InvalidArgumentException('Business must be a string');
}
return Business::from($business);
},
$this->array('business'),
);
}
public function getIsEgalim(): ?bool
{
$egalim = $this->input('egalim');
if (is_array($egalim) && (in_array(1, $egalim, true) || in_array('1', $egalim, true))) {
return true;
}
return null;
}
public function getSearchValue(): ?string
{
return $this->has('search') && ! empty($this->string('search')->value()) ?
$this->string('search')->value() : null;
}
public function hasFilter(): bool
{
return count($this->getCategories()) > 0
|| count($this->getBreedingMethods()) > 0
|| count($this->getTechnologies()) > 0
|| count($this->getBusinesses()) > 0
|| $this->getIsEgalim() !== null
|| $this->getSearchValue() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Validation\Rule|array<mixed>|string>
*/
public function rules(): array
{
return [
'search' => ['nullable', 'string'],
'category' => ['nullable', 'array'],
'category.*' => ['string'],
'breeding' => ['nullable', 'array'],
'breeding.*' => ['string'],
'technology' => ['nullable', 'array'],
'technology.*' => ['string'],
'business' => ['nullable', 'array'],
'business.*' => ['string'],
'egalim' => ['nullable', 'array'],
'egalim.*' => ['numeric'],
];
}
}

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Front\Recipes;
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\Foundation\Http\FormRequest;
class IndexRecipeRequest extends FormRequest
{
/** @return \Domain\Models\ValueObjects\Products\ProductCategory[] */
public function getCategories(): array
{
return array_map(
function ($category) {
if (! is_string($category)) {
throw new \InvalidArgumentException('Category must be a string');
}
return ProductCategory::from($category);
},
$this->array('category'),
);
}
/** @return \Domain\Models\ValueObjects\Recipes\ConsumptionTime[] */
public function getConsumptionTimes(): array
{
return array_map(
function ($time) {
if (! is_string($time)) {
throw new \InvalidArgumentException('Consumption time must be a string');
}
return ConsumptionTime::from($time);
},
$this->array('consumption_time'),
);
}
/** @return \Domain\Models\ValueObjects\Recipes\Inspiration[] */
public function getInspirations(): array
{
return array_map(
function ($inspiration) {
if (! is_string($inspiration)) {
throw new \InvalidArgumentException('Inspiration must be a string');
}
return Inspiration::from($inspiration);
},
$this->array('inspiration'),
);
}
/** @return \Domain\Models\ValueObjects\Recipes\Season[] */
public function getSeasons(): array
{
return array_map(
function ($season) {
if (! is_string($season)) {
throw new \InvalidArgumentException('Season must be a string');
}
return Season::from($season);
},
$this->array('season'),
);
}
public function getSearchValue(): ?string
{
return $this->has('search') && ! empty($this->string('search')->value()) ?
$this->string('search')->value() : null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Validation\Rule|array<mixed>|string>
*/
public function rules(): array
{
return [
'search' => ['nullable', 'string'],
'category' => ['nullable', 'array'],
'category.*' => ['string'],
'consumption_time' => ['nullable', 'array'],
'consumption_time.*' => ['string'],
'inspiration' => ['nullable', 'array'],
'inspiration.*' => ['string'],
'season' => ['nullable', 'array'],
'season.*' => ['string'],
];
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Gate;
use Uwil\LaravelBackend\Resources\CommonResource as BaseCommonResource;
class CommonResource extends BaseCommonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$data = parent::toArray($request);
$resource = $this->resource;
if (! is_object($resource) || ! ($resource instanceof Model)) {
throw new \InvalidArgumentException('Resource must be an instance of ' . Model::class);
}
$modelClass = $resource::class;
$data['modelClass'] = $modelClass;
$data['policies'] = Arr::wrap($data['policies'] ?? []);
$data['policies']['create'] = Gate::allows('create', $modelClass);
$data['policies']['viewAny'] = Gate::allows('viewAny', $modelClass);
return $data;
}
}

View file

@ -0,0 +1,529 @@
<?php
declare(strict_types=1);
namespace App\Lib\Helpers;
use App\Exceptions\Helpers\FileStorageOperationException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* This class is used to handle files in LDC Laravel stack
*
* * The storage strategy will be using date.
* The file name may contain sufix timestamp highresolution time (hrtime or microtime) if is duplicate.
* (function() { return time() . ' ' . Str::slug(microtime(true)) . ' ' . hrtime(true); })();
*
* posts/ - The model table name
* 10/ - Year
* 04/ - Month
* 19/ - Day
* 5f/ - First two letters of sha256 file hash
* black-cat-under-red-car.png - Slugified file name
* black-cat-under-red-car-286071690833400.png - Slugified file name that is duplicated
*
* The final path will be storage/modelfiles/posts/10/04/19/5f/black-cat-under-red-car-286071690833400.png
*
* ! No limits of file per folder will be handled.
*/
class FileStorageHelper
{
private const string STORAGE_PREFIX = 'storage/';
/**
* Remove all files from string attribute html that are stored in public
*
* @param string $publicStorageFilePath The file path from public directory.
* @return string The local storage path.
*/
public static function getFileFullPath(string $publicStorageFilePath): string
{
/** @var \Illuminate\Filesystem\FilesystemAdapter */
$disk = Storage::disk('public');
$path = Str::replace(self::STORAGE_PREFIX, '', \ltrim(\urldecode($publicStorageFilePath), '/'));
return $disk->path($path);
}
/**
* Destroy the file associate with the attribute.
*/
public static function removeFile(Model $model, string $attribute): void
{
$attributeValue = $model->getAttribute($attribute);
if (! is_scalar($attributeValue)) {
return;
}
$path = \ltrim(\urldecode(strval($attributeValue)), '/');
if (! str_starts_with($path, 'storage')) {
return;
}
$path = Str::replace(self::STORAGE_PREFIX, '', $path);
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
}
/**
* Destroy the file associate with the attribute old value (before updated to DB).
*/
public static function removeOldFile(Model $model, string $attribute, bool $onlyIfUpdated = true): void
{
$originalValue = ToolboxHelper::getOriginal($model, $attribute);
// * Ignore If removal has to be done only if the field has changed and it actually hasnt.
if ($onlyIfUpdated && $model->{$attribute} === $originalValue) {
return;
}
if (! is_string($originalValue)) {
return;
}
$originalPath = \ltrim(\urldecode($originalValue), '/');
if (! str_starts_with($originalPath, 'storage')) {
return;
}
$originalPath = Str::replace(self::STORAGE_PREFIX, '', $originalPath);
if (Storage::disk('public')->exists($originalPath)) {
Storage::disk('public')->delete($originalPath);
}
}
/**
* Store a file using optimized storage strategy
*
* You can use filename param to overload file name.
*
* @param Model $model The model that will hold the picture.
* @param UploadedFile|\SplFileInfo|string $file The file, will just return itself is string.
* @param bool $slugify Force filename slugification.
* @param string|null $filename This string may be used to store the file.
* @param bool $private To store the file in a private space.
*
* @throws FileStorageOperationException If file copy fails.
*
* @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh
*/
public static function storeFile(
Model $model,
UploadedFile | \SplFileInfo | string | null $file,
bool $slugify = false,
?string $filename = null,
bool $private = false
): string {
// phpcs:enable
$tableName = $model->getTable();
if ($file instanceof UploadedFile) {
return self::storeUploadedFile($file, $tableName, $slugify, $filename, $private);
}
if ($file instanceof \SplFileInfo) {
return self::storeSplFileInfo($file, $tableName, $slugify, $filename, $private);
}
return (string) $file;
}
private static function storeUploadedFile(
UploadedFile $file,
string $tableName,
bool $slugify,
?string $filename,
bool $private
): string {
$filename = $filename ?: $file->getClientOriginalName();
$filename = $slugify ? self::slugifyFileName($filename) : $filename;
$disk = $private ? 'private' : 'public';
$newPath = str_replace(
storage_path("app/{$disk}"),
'',
self::prepareMoveFile($tableName, $filename, $private)
);
$directory = pathinfo($newPath, \PATHINFO_DIRNAME);
$basename = pathinfo($newPath, \PATHINFO_BASENAME);
if (! $private) {
$file->storePubliclyAs(
$directory,
$basename,
['disk' => $disk]
);
} else {
$file->storeAs(
$directory,
$basename,
['disk' => $disk]
);
}
return "storage{$newPath}";
}
private static function storeSplFileInfo(
\SplFileInfo $file,
string $tableName,
bool $slugify,
?string $filename,
bool $private
): string {
$filename = $filename ?: $file->getBaseName();
$filename = $slugify ? self::slugifyFileName($filename) : $filename;
$newPath = self::prepareMoveFile($tableName, $filename, $private);
if (! File::copy($file->getRealPath(), $newPath)) {
throw FileStorageOperationException::copyFailed($file->getRealPath(), $newPath);
}
if ($private) {
return \ltrim(str_replace(\storage_path('app/private'), '/storage/app/private', $newPath), '/');
}
return \ltrim(str_replace(\storage_path('app/public'), '/storage', $newPath), '/');
}
/**
* Store all files from string attribute html
* If oldHtmlString is passed, a diff is done with new and old files
* to keep only files that are present in the new html string.
*
* @param Model $model The model that will hold the picture.
* @param string|null $htmlString The html string to parse and modify files.
* @param string|null $oldHtmlString The old html string with alreadyParsed
* files using storeModelFilesFromHtml.
* @return string|null Html with files pathes replaced using storage strategy.
*/
public static function storeModelFilesFromHtml(
Model $model,
?string $htmlString,
?string $oldHtmlString = null
): ?string {
if (is_null($htmlString)) {
return null;
}
Collection::make(self::onlyUploadedFilesWithFileManager(self::findFilesInHtml($htmlString)))
->each(function (string $path) use ($model, &$htmlString): void {
/** @var \Illuminate\Filesystem\FilesystemAdapter */
$disk = Storage::disk('private');
$fileInfo = new \SplFileInfo($disk->path(
ToolboxHelper::mbReplaceSingle('/uploads/', '', \urldecode($path))
));
$htmlString = ToolboxHelper::mbReplaceSingle(
$path,
'/' . self::storeFile($model, $fileInfo, true),
$htmlString
);
});
if ($oldHtmlString) {
self::removeOldModelFilesFromHtml($htmlString, $oldHtmlString);
}
return $htmlString;
}
/**
* Remove all old files from string attribute html that are stored in public that
* are nove present anymore on new html string
*
* @param string $newHtmlString The html string with alreadyParsed files using storeModelFilesFromHtml.
* @param string $oldHtmlString The old html string with alreadyParsed files using storeModelFilesFromHtml.
*/
public static function removeOldModelFilesFromHtml(string $newHtmlString, string $oldHtmlString): void
{
self::removeOldFiles(self::findFilesInHtml($oldHtmlString), self::findFilesInHtml($newHtmlString));
}
/**
* Remove all old files that are stored in public that
* are not present anymore on new array files
*
* @param string[] $oldFiles The old array files.
* @param string[] $newFiles The new array files.
*/
public static function removeOldFiles(array $oldFiles, array $newFiles): void
{
$oldFiles = Collection::make(self::onlyUploadedFilesWithFileStorageHelper($oldFiles));
$filesToKeep = Collection::make(self::onlyUploadedFilesWithFileStorageHelper($newFiles));
$filesToRemove = $oldFiles->diff($filesToKeep);
$filesToRemove->each(function (string $publicStorageFilePath): void {
$path = Str::replace(self::STORAGE_PREFIX, '', \ltrim(\urldecode($publicStorageFilePath), '/'));
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
});
}
/**
* Remove all files from string attribute html that are stored in public
*
* @param string $htmlString The html string with alreadyParsed files using storeModelFilesFromHtml.
*/
public static function removeModelFilesOfHtml(string $htmlString): void
{
$filesToRemove = Collection::make(self::onlyUploadedFilesWithFileStorageHelper(self::findFilesInHtml($htmlString)));
$filesToRemove->each(function (string $publicStorageFilePath): void {
$path = Str::replace(self::STORAGE_PREFIX, '', \ltrim(\urldecode($publicStorageFilePath), '/'));
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
});
}
/**
* Find all occurence of files in html
*
* @param string $html The html string to parse and find files.
* @return string[] The array of files found in html.
*/
public static function findFilesInHtml(string $html): array
{
preg_match_all(
'/(?:href|src|url)="([^"]*)"/Ui',
$html,
$matches
);
return Collection::make($matches[1])->unique()->filter()->values()->all();
}
/**
* Filter array to get only uploaded files that exists
* on disk 'public' (using helper storage strategy)
*
* @param string[] $urls The array of urls to filter
* @return string[] The filtered array with only uploaded files that exists on disk 'public
*/
public static function onlyUploadedFilesWithFileStorageHelper(array $urls): array
{
return Collection::make($urls)->unique()
// * Filter to get only /uploads and using app url or empty
->filter(function (string $url) {
$path = (string) (\parse_url($url, \PHP_URL_PATH) ?? '');
return str_starts_with(\ltrim($path, '/'), 'storage/modelfiles');
})->filter(function (string $url) {
$path = (string) (\parse_url($url, \PHP_URL_PATH) ?? '');
return Storage::disk('public')
->exists(Str::replace(self::STORAGE_PREFIX, '', \ltrim(\urldecode($path), '/')));
})->all();
}
/**
* Filter array to get only uploaded files that exist on disk 'private'
*
* @param string[] $urls The array of urls to filter
* @return string[] The filtered array
*/
private static function onlyUploadedFilesWithFileManager(array $urls): array
{
$appUrl = Config::string('app.url');
if (empty($appUrl)) {
throw new \UnexpectedValueException('App URL must be set in config to filter uploaded files with file manager');
}
$appHost = parse_url((string) $appUrl, PHP_URL_HOST);
return Collection::make($urls)->unique()->filter(function (string $url) use ($appHost) {
$host = parse_url($url, PHP_URL_HOST);
// 1. Validation du domaine (Doit être identique à l'app ou être un chemin relatif)
if ($host !== null && $host !== $appHost) {
return false;
}
// 2. Extraction et nettoyage du chemin
$path = ltrim(urldecode((string) parse_url($url, PHP_URL_PATH)), '/');
// 3. Vérification du préfixe 'uploads/' et existence sur le disque
if (! str_starts_with($path, 'uploads/')) {
return false;
}
// On retire 'uploads/' pour tester l'existence sur le disque 'private'
$diskPath = substr($path, strlen('uploads/'));
return Storage::disk('private')->exists($diskPath);
})->values()->all();
}
/**
* Prepare storage path and return
* the file full path
*
* @param string $tableName Model table name.
* @param string $filename The basename for the file.
* @param bool $private May the file be stored in a private dir.
*/
private static function prepareMoveFile(string $tableName, string $filename, bool $private = false): string
{
// * Security filter
$filename = \htmlspecialchars($filename);
// * Security filter + beautifier
$filename = self::sanitizeFileName($filename);
// * Slugify filename
$ext = \pathinfo($filename, \PATHINFO_EXTENSION);
$filename = Str::slug(\pathinfo($filename, \PATHINFO_FILENAME)) .
(\strlen($ext) ? ".{$ext}" : '');
$folderPath = self::getStoragePath(
$private ? 'app/private/modelfiles' : 'app/public/modelfiles',
$tableName,
$filename
);
$filename = self::getFileUniqueName($folderPath, $filename);
return "{$folderPath}/{$filename}";
}
/**
* Create sub folders to store files using pattern
* storage/modelfiles/posts/10/04/19/5f
* year/month/day/sha256 first 2 letters
*/
private static function getStoragePath(string $storage_dir, string $table, string $filename): string
{
$filenameHash = \hash('sha256', $filename);
$pathFolder = \storage_path(sprintf(
'%s/%s/%s/%s/%s/%s',
$storage_dir,
$table,
gmdate('y'),
gmdate('m'),
gmdate('d'),
substr($filenameHash, 0, 2)
));
if (! File::exists($pathFolder)) {
File::makeDirectory($pathFolder, 0755, true);
}
return $pathFolder;
}
/**
* Get unique filename if the path is already occupied
*
* @throws FileStorageOperationException If a unique filename cannot be obtained.
*/
private static function getFileUniqueName(string $folderPath, string $filename): string
{
$attempts = 0;
$maxAttempts = 20;
while (File::exists("{$folderPath}/{$filename}")) {
if ($attempts++ >= $maxAttempts) {
throw FileStorageOperationException::cannotGenerateUniqueFilename($folderPath, $filename);
}
$filename = Str::of(pathinfo($filename, \PATHINFO_FILENAME))
->append(Str::random(8) . '-')
->append('.')
->append(pathinfo($filename, \PATHINFO_EXTENSION))
->toString();
}
return $filename;
}
/**
* Slugify the filename
*/
public static function slugifyFileName(string $filename): string
{
return Str::slug(pathinfo($filename, \PATHINFO_FILENAME)) . '.' . pathinfo($filename, \PATHINFO_EXTENSION);
}
/**
* Sanitize File name for uploaded files
*
* @param string $filename The original uploaded client file name.
* @param bool $beautify Beautify the filename.
* @return string The sanitized file name.
*/
public static function sanitizeFileName(string $filename, bool $beautify = true): string
{
$filename = preg_replace('/[<>:"\/\\|?*\x00-\x1F\x7F\xA0\xAD#\[\]@!$&\'()+,;={}^~`]/u', '-', $filename) ?? $filename;
// * Avoids '.' '..' or '.hiddenFiles' .
$filename = (string) Str::of($filename)->ltrim('.-');
// * Optional beautification
if ($beautify) {
$filename = self::beautifyFilename($filename);
}
// * Maximize filename length to 255 bytes http://serverfault.com/a/9548/44086
/** @var string */
$extension = pathinfo($filename, \PATHINFO_EXTENSION);
/** @var string */
$filename = pathinfo($filename, \PATHINFO_FILENAME);
$encoding = mb_detect_encoding($filename);
$encoding = $encoding ?: null;
return \mb_strcut(
$filename,
0,
255 - (! empty($extension) ? strlen($extension) + 1 : 0),
$encoding
) . (! empty($extension) ? ".$extension" : '');
}
/**
* Beautify a filename
*/
private static function beautifyFilename(string $filename): string
{
// Reduce consecutive characters.
$filename = preg_replace([
// * "file name.zip" becomes 'file-name.zip'
'/ +/',
// * "file___name.zip" becomes 'file-name.zip'
'/_+/',
// * "file---name.zip" becomes 'file-name.zip'
'/-+/',
], '-', $filename) ?? $filename;
$filename = preg_replace([
// * "file--.--.-.--name.zip" becomes "file.name.zip"
'/-*\.-*/',
// * "file...name..zip" becomes "file.name.zip"
'/\.{2,}/',
], '.', $filename) ?? $filename;
// Lowercase for windows/unix interoperability http://support.microsoft.com/kb/100625 .
$encoding = mb_detect_encoding($filename);
$filename = mb_strtolower($filename, $encoding ?: 'UTF-8');
// * ".file-name.-" becomes "file-name"
$filename = trim($filename, '.-');
return $filename;
}
/**
* Remove all model files for the given Model.
*/
public static function removeModelFiles(Model $model): void
{
$tableName = $model::query()->getModel()->getTable();
if (! empty(Storage::disk('public')->allFiles("modelfiles/{$tableName}"))) {
Storage::disk('public')->deleteDirectory("modelfiles/{$tableName}");
}
if (! empty(Storage::disk('private')->allFiles("modelfiles/{$tableName}"))) {
Storage::disk('private')->deleteDirectory("modelfiles/{$tableName}");
}
}
public static function isMimeType(string $mimeType, \SplFileInfo $file): bool
{
if (! $file->getRealPath()) {
return false;
}
$actualMime = File::mimeType($file->getRealPath());
return $actualMime ? Str::is($mimeType, $actualMime) : false;
}
}

View file

@ -0,0 +1,408 @@
<?php
declare(strict_types=1);
namespace App\Lib\Helpers;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Symfony\Component\Finder\SplFileInfo;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ToolboxHelper extends ToolboxHelperCore
{
/**
* @param array<int|string, mixed> $array1
* @param array<int|string, mixed> $array2
* @return array<int|string, mixed>
*/
public static function arrayMergeRecursiveDistinct(array &$array1, array &$array2): array
{
$merged = $array1;
foreach ($array2 as $key => &$value) {
if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) {
$merged[$key] = self::arrayMergeRecursiveDistinct($merged[$key], $value);
} else {
$merged[$key] = $value;
}
}
return $merged;
}
/**
* Strip scheme (https) on oembed urls
* Only embed allowed are for MP4 self hosted videos right now
*
* https is force by ckeditor MediaElements which natively supports
* only cloud hosted medias (youtube, spotify etc..)
*/
public static function stripCkEditorSchemesOnMediaInputs(string $input): string
{
return Str::of($input)
->replaceMatches('/(?<=<oembed url=")(http:\/\/)(?=.*?">)/m', '')
->toString();
}
/**
* Asserts that the field is unique.
*
* @param array<mixed>|string|int|float|bool|null $value
*
* @throws \Illuminate\Validation\ValidationException If the field is not unique.
*/
public static function assertFieldIsUnique(
string $table,
string $field,
string | int | float | bool | null | array $value,
null | int | string $id = null,
\Closure | string | null $query = null
): void {
if (! $query) {
Validator::make([$field => $value], [
(string) str_replace('.', '\.', $field) => Rule::unique($table, $field)->ignore($id),
], [
(string) str_replace('.', '\.', "{$field}.unique") => trans(
'crud.libs.toolbox.assertFieldIsUnique',
['attribute' => $field, 'value' => is_array($value) ? json_encode($value) : $value]
),
])->validate();
return;
}
Validator::make([$field => $value], [
$field => Rule::unique($table)->where($query)->ignore($id),
], [
$field . '.unique' => trans(
'crud.libs.toolbox.assertFieldIsUnique',
['attribute' => $field, 'value' => is_array($value) ? json_encode($value) : $value]
),
])->validate();
}
/**
* Assert That no Back Office url is present on txt
*
* @throws \Illuminate\Validation\ValidationException If url is a bo url.
*/
public static function assertNoInternalBoUrls(string $field, string $text): string
{
$routes = Route::getRoutes();
return self::replaceUrlsInText($text, function (string $url) use ($routes, $field): void {
$request = Request::create($url);
if (! self::isInternalHost($url)) {
return;
}
if (trim($routes->match($request)->getPrefix() ?? '', '/') === 'bo') {
throw ValidationException::withMessages([
$field => trans(
'crud.libs.toolbox.assertNoInternalBoUrls',
['attribute' => $field]
),
]);
}
});
}
/**
* Assert That only external url is present on txt
*
* @throws \Illuminate\Validation\ValidationException If url is a bo url.
*/
public static function assertNoInternalUrls(string $field, string $text): string
{
self::assertNoInternalBoUrls($field, $text);
$routes = Route::getRoutes();
return self::replaceUrlsInText($text, function (string $url) use ($routes, $field): void {
$request = Request::create($url);
$routes->match($request);
if (! self::isInternalHost($url)) {
return;
}
throw ValidationException::withMessages([
$field => trans(
'crud.libs.toolbox.assertNoInternalUrls',
['attribute' => $field, 'appname' => Config::string('app.name')]
),
]);
});
}
private static function isInternalHost(string $url): bool
{
return \parse_url((string) Config::string('app.url'), \PHP_URL_HOST) === \parse_url($url, \PHP_URL_HOST);
}
private static function replaceUrlsInText(string $text, callable $onMatchedUrl): string
{
$result = preg_replace_callback(
// phpcs:ignore Generic.Files.LineLength.TooLong
'/(?P<url>(?:https?:\/\/)?(?:www\.)?[-a-zA-Z0-9@%._+~#=]{2,256}\.[a-z]{2,6}\b[-a-zA-Z0-9@:%_.+~#?&\/=]*)/',
function (array $matches) use ($onMatchedUrl): string {
$match = collect($matches)->first() ?? '';
$url = (string) $match;
try {
$onMatchedUrl($url);
} catch (NotFoundHttpException) {
return (string) $match;
}
return (string) $match;
},
$text
);
return is_string($result) ? $result : $text;
}
/**
* Get original polyfill with autotranslate behavior
*/
public static function getOriginal(Model $model, string $attribute): mixed
{
if (
\in_array('Spatie\Translatable\HasTranslations', \class_uses_recursive($model)) && // @phpstan-ignore-next-line
$model->isTranslatableAttribute($attribute)
) {
// @phpstan-ignore-next-line
return $model->getTranslation(
$attribute,
// @phpstan-ignore-next-line
$model->getLocale(),
// @phpstan-ignore-next-line
$model->useFallbackLocale()
);
}
return $model->getOriginal($attribute);
}
/**
* Generates an array from a dot separated string
*
* @param string $path The dot separated string to convert to an array.
* @param mixed $value The value to set at the end of the array.
* @param string $separator The separator used in the path (default is '.').
* @return array<string,mixed> The generated array.
*/
public static function dotStringToNestedArray(string $path, mixed $value, string $separator = '.'): array
{
throw_if($separator === '', \InvalidArgumentException::class, 'Separator cannot be an empty string.');
$arr = $value;
$keys = explode($separator, $path);
$keys = array_reverse($keys);
foreach ($keys as $key) {
$arr = [$key => $arr];
}
return $arr;
}
/**
* Validate pdf
*
* @param callable(string):void $fail A callback that should be called if the validation fails.
*
* @throws \Illuminate\Validation\ValidationException If the pdf is invalid.
*/
public static function validatePdf(string $attribute, ?string $value, callable $fail): void
{
if ($value === null) {
return;
}
if (! ($useStorage = Storage::exists($value)) && ! File::exists($value)) {
$fail(trans('validation.custom.has_picture.file_does_not_exist', [
'attribute' => $attribute,
]));
return;
}
if (self::getUploadedFileMimeType($value, $useStorage) !== 'application/pdf') {
$fail(trans('validation.mimetypes', [
'attribute' => $attribute,
'values' => 'application/pdf',
]));
}
}
/**
* Validate picture
*
* @param callable(string):void $fail A callback that should be called if the validation fails.
* @param object{
* minWidth: int,
* maxWidth: int,
* minHeight: int,
* maxHeight: int
* }&\stdClass $sizes An object containing the minimum and maximum width and height for the picture.
*
* @throws \Illuminate\Validation\ValidationException If the picture is invalid.
*/
public static function validatePicture(string $attribute, ?string $value, callable $fail, object $sizes): void
{
if ($value === null) {
return;
}
if (! ($useStorage = Storage::exists($value)) && ! File::exists($value)) {
$fail(trans('validation.custom.has_picture.file_does_not_exist', [
'attribute' => $attribute,
]));
return;
}
$fileFullPath = self::getUploadedFileFullPath($value, $useStorage);
if (
! \in_array(self::getUploadedFileMimeType($value, $useStorage), [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
])
) {
$fail(trans('validation.mimetypes', [
'attribute' => $attribute,
'values' => 'image/jpeg, image/jpg, image/png, image/gif',
]));
}
if (! ($dimensions = \getimagesize($fileFullPath))) {
$fail(trans('validation.dimensions', [
'attribute' => $attribute,
]));
return;
}
$width = intval($dimensions[0]);
$height = intval($dimensions[1]);
$xMessage = trans('validation.custom.has_picture.width_between', [
'attribute' => $attribute,
'min' => "{$sizes->minWidth}px",
'max' => "{$sizes->maxWidth}px",
]);
if ($sizes->minWidth === $sizes->maxWidth) {
$xMessage = trans('validation.custom.has_picture.width_equal', [
'attribute' => $attribute,
'value' => "{$sizes->minWidth}px",
]);
}
$yMessage = trans('validation.custom.has_picture.height_between', [
'attribute' => $attribute,
'min' => "{$sizes->minHeight}px",
'max' => "{$sizes->maxHeight}px",
]);
if ($sizes->minHeight === $sizes->maxHeight) {
$yMessage = trans('validation.custom.has_picture.height_equal', [
'attribute' => $attribute,
'value' => "{$sizes->minHeight}px",
]);
}
if (($width < $sizes->minWidth) || ($width > $sizes->maxWidth)) {
$fail($xMessage);
}
if (($height < $sizes->minHeight) || ($height > $sizes->maxHeight)) {
$fail($yMessage);
}
}
/**
* Fetch class list from a specific namespace
*
* @return array<string> An array of classe namespaces
*/
public static function getClassesUsingNamespace(string $namespace): array
{
return collect(File::files(\app_path((string) Str::of($namespace)->replaceFirst('\App', ''))))
->filter(fn (SplFileInfo $splFileInfo) => $splFileInfo->getExtension() === 'php')
->map(fn (SplFileInfo $splFileInfo) => (string) Str::of($splFileInfo->getBasename())
->replace('.' . $splFileInfo->getExtension(), '')->prepend("{$namespace}\\"))->all();
}
/**
* Get all classes from a specific namespace that implement a specific interface
*
* @return array<string> An array of classe namespaces
*/
public static function getClassesUsingInterface(string $namespace, string $interface): array
{
return Arr::where(
static::getClassesUsingNamespace($namespace),
fn (string $class_namespace) => \collect(class_implements($class_namespace, true) ?: [])->filter()
->contains(fn (string $class_interface) => $class_interface === $interface)
);
}
/**
* Convert Hex color to RGB
*
* @return int[]
*/
public static function hexToRgb(string $hexcolor): array
{
$hexcolor = (string) str($hexcolor)->replaceStart('#', '');
$r = (int) hexdec(substr($hexcolor, 0, 2));
$g = (int) hexdec(substr($hexcolor, 2, 2));
$b = (int) hexdec(substr($hexcolor, 4, 2));
return [$r, $g, $b];
}
/**
* Get text color contrast for a hex background color
*
* @return string "black" or "white"
*/
public static function getContrastYiq(string $hexcolor): string
{
[$r, $g, $b] = self::hexToRgb($hexcolor);
$yiq = (($r * 299) + ($g * 587) + ($b * 114)) / 1000;
return ($yiq >= 128) ? 'black' : 'white';
}
/**
* Convert an HTML string to plain text with proper word spacing.
*
* Replaces HTML tags with spaces, strips remaining tags,
* normalises whitespace and trims the result.
*
* @param string|null $html The HTML content to convert.
* @param int|null $limit Optional character limit (uses Str::limit).
*/
public static function htmlToText(?string $html, ?int $limit = null): string
{
if ($html === null || $html === '') {
return '';
}
$text = strip_tags((string) preg_replace('/<[^>]*>/', ' ', $html));
$text = trim((string) preg_replace('/\s+/', ' ', $text));
if ($limit !== null) {
return Str::limit($text, $limit);
}
return $text;
}
}

View file

@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\Lib\Helpers;
use App\Exceptions\Helpers\UnsupportedStructureConversionException;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
abstract class ToolboxHelperCore
{
/**
* Multibyte-safe str_replace with unified dispatch.
*
* Returns false when $replace is an array but $search is a scalar string.
*
* @param array<string>|string $search
* @param array<string>|string $replace
* @param array<int|string, mixed>|string $subject
* @return array<int|string, mixed>|string|false
*/
public static function mbReplace(
array | string $search,
array | string $replace,
array | string $subject,
int &$count = 0,
): array | string | false {
if (is_array($subject)) {
return self::mbReplaceInArraySubject($search, $replace, $subject, $count);
}
return self::mbReplaceScalar($search, $replace, $subject, $count);
}
/**
* Dispatch for scalar subject.
* Returns false when $replace is array but $search is a scalar string.
*
* @param array<string>|string $search
* @param array<string>|string $replace
*/
private static function mbReplaceScalar(
array | string $search,
array | string $replace,
string $subject,
int &$count = 0,
): string | false {
if (! is_array($search) && is_array($replace)) {
return false;
}
if (is_array($search)) {
return self::mbReplaceWithSearchArray($search, $replace, $subject, $count);
}
if (! is_string($replace)) {
throw new \InvalidArgumentException('Replace must be a string when search is a string.');
}
return self::mbReplaceSingle($search, $replace, $subject, $count);
}
/**
* @param string[]|string $search
* @param string[]|string $replace
* @param array<int|string, mixed> $subject
* @return array<int|string, mixed>
*/
public static function mbReplaceInArraySubject(array | string $search, array | string $replace, array $subject, int &$count = 0): array
{
foreach ($subject as &$value) {
if (is_array($value)) {
$value = self::mbReplaceInArraySubject($search, $replace, $value, $count);
} elseif (is_array($search) && is_string($value)) {
$value = self::mbReplaceWithSearchArray($search, $replace, $value, $count);
} elseif (! is_array($replace) && is_string($value)) {
$value = self::mbReplaceSingle($search, $replace, $value, $count);
}
}
unset($value);
return $subject;
}
/**
* @param string[] $search
* @param array<string>|string $replace
*/
public static function mbReplaceWithSearchArray(array $search, array | string $replace, string $subject, int &$count = 0): string
{
if (! is_array($replace)) {
foreach ($search as $searchItem) {
$subject = (string) self::mbReplaceSingle($searchItem, $replace, $subject, $count);
}
return $subject;
}
$iterations = max(count($search), count($replace));
$searchItem = reset($search);
$replaceItem = reset($replace);
while ($iterations--) {
$subject = (string) self::mbReplaceSingle((string) $searchItem, (string) $replaceItem, $subject, $count);
$searchItem = next($search);
$replaceItem = next($replace);
}
return $subject;
}
public static function mbReplaceSingle(string $search, string $replace, string $subject, int &$count = 0): string
{
$parts = mb_split(preg_quote($search), $subject) ?: [];
$count = count($parts) - 1;
return implode($replace, $parts);
}
/**
* Convert deeply an object to array
*
* @return array<int|string,mixed>
*
* @throws \App\Exceptions\Helpers\UnsupportedStructureConversionException If $input var is a scalar value.
*/
public static function objectToArray(mixed $input): array
{
if (! (is_object($input) || is_array($input))) {
throw UnsupportedStructureConversionException::fromType($input);
}
$ret = (array) $input;
foreach ($ret as &$item) {
$item = (is_object($item) || is_array($item)) ? self::objectToArray($item) : $item;
}
return $ret;
}
/**
* Convert deeply an array to object
*
* @throws \App\Exceptions\Helpers\UnsupportedStructureConversionException If $input var is a scalar value.
*/
public static function arrayToObject(mixed $input): object
{
if (! (is_object($input) || is_array($input))) {
throw UnsupportedStructureConversionException::fromType($input);
}
$ret = (object) $input;
$retArray = (array) $ret;
foreach ($retArray as &$item) {
$item = (is_object($item) || is_array($item)) ? self::arrayToObject($item) : $item;
}
unset($item);
return (object) $retArray;
}
/**
* Get the file full path
*/
protected static function getUploadedFileFullPath(string $value, bool $useStorage): string
{
/** @var \Illuminate\Filesystem\FilesystemAdapter */
$disk = Storage::disk();
if ($useStorage) {
return $disk->path($value);
}
return realpath($value) ?: $value;
}
/**
* Get the file mimetype
*
* @return string|false
*/
protected static function getUploadedFileMimeType(string $value, bool $useStorage)
{
if ($useStorage) {
return Storage::mimeType($value);
}
return File::mimeType($value);
}
}

195
app/Models/Product.php Normal file
View file

@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Casts\NutritionalValuesCast;
use App\Lib\Helpers\FileStorageHelper;
use App\Lib\Helpers\ToolboxHelper;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Models\ValueObjects\Products\BreedingMethod;
use Domain\Models\ValueObjects\Products\Business;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Domain\Models\ValueObjects\Products\ProductFormat;
use Domain\Models\ValueObjects\Products\Technology;
use Illuminate\Database\Eloquent\Casts\AsEnumCollection;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
use Laravel\Scout\Searchable;
use Ramsey\Uuid\Uuid;
use Spatie\Translatable\HasTranslations;
use Uwil\LaravelBackend\Interfaces\TranslatableModel;
use Uwil\LaravelBackend\Traits\Models\HasActivityLog;
use Uwil\LaravelBackend\Traits\Models\HasCache;
use Uwil\LaravelBackend\Traits\Models\HasModelNaming;
use Uwil\LaravelBackend\Traits\Models\HasUwilImages;
// phpcs:disable Generic.Files.LineLength.TooLong
/**
* @property string $id
* @property string $seo_title
* @property string $seo_description
* @property string $picture
* @property string $picture_alt
* @property string $picture_title
* @property string $name
* @property string $slug
* @property ProductCategory $category Domain\Models\ValueObjects\Products\ProductRange
* @property Technology $technology Domain\Models\ValueObjects\Products\Technology
* @property bool $is_egalim
* @property \Illuminate\Support\Collection<int, BreedingMethod> $breeding_methods Array of Domain\Models\ValueObjects\Products\BreedingMethod
* @property \Illuminate\Support\Collection<int, Business> $businesses Array of Domain\Models\ValueObjects\Products\Business
* @property \Illuminate\Support\Collection<int, ProductFormat> $formats Array of Domain\Models\ValueObjects\Products\ProductFormat
* @property string $description
* @property string $details_and_advantages
* @property string $handling_advices
* @property string|null $ingredients
* @property string $conservation_before_opening
* @property string|null $conservation_after_opening
* @property \Domain\Models\ValueObjects\Products\NutritionalValues|null $nutritional_values Domain\Models\ValueObjects\NutritionalValues
* @property string|null $pdf
* @property \Illuminate\Support\Carbon|null $deleted_at
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Uwil\LaravelBackend\Models\ActivityLog> $activityLogs
* @property-read array $translatable_columns_from
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Recipe> $recipes
* @property-read mixed $translations
*
* @method static \Database\Factories\ProductFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|Product onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Product whereJsonContainsLocale(string $column, string $locale, ?mixed $value, string $operand = '=')
* @method static \Illuminate\Database\Eloquent\Builder<static>|Product whereJsonContainsLocales(string $column, array $locales, ?mixed $value, string $operand = '=')
* @method static \Illuminate\Database\Eloquent\Builder<static>|Product whereLocale(string $column, string $locale)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Product whereLocales(string $column, array $locales)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Product withTrashed(bool $withTrashed = true)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Product withoutTrashed()
*
* @mixin \Eloquent
*/
// phpcs:enable Generic.Files.LineLength.TooLong
class Product extends Model implements TranslatableModel
{
use HasActivityLog;
use HasCache;
/** @use HasFactory<\Database\Factories\ProductFactory> */
use HasFactory;
use HasModelNaming;
use HasTranslations;
use HasUuids;
use HasUwilImages;
use Searchable;
use SoftDeletes;
protected $fillable = [
'seo_title',
'seo_description',
'picture',
'picture_alt',
'picture_title',
'name',
'category',
'technology',
'is_egalim',
'breeding_methods',
'businesses',
'formats',
'description',
'details_and_advantages',
'handling_advices',
'ingredients',
'conservation_before_opening',
'conservation_after_opening',
'nutritional_values',
'pdf',
];
protected $casts = [
'is_egalim' => 'boolean',
'category' => ProductCategory::class,
'technology' => Technology::class,
'breeding_methods' => AsEnumCollection::class . ':' . BreedingMethod::class,
'businesses' => AsEnumCollection::class . ':' . Business::class,
'formats' => AsEnumCollection::class . ':' . ProductFormat::class,
'nutritional_values' => NutritionalValuesCast::class,
];
public function newUniqueId(): string
{
return Uuid::uuid7()->toString();
}
public function getId(): ProductId
{
return new ProductId(Uuid::fromString(strval($this->id)));
}
#[\Override]
protected static function booted(): void
{
static::saving(function (self $model): void {
self::manageFields($model);
});
static::deleting(function (self $model): void {
FileStorageHelper::removeFile($model, 'picture');
FileStorageHelper::removeFile($model, 'pdf');
});
}
/** @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<\App\Models\Recipe,$this> */
public function recipes(): BelongsToMany
{
return $this->belongsToMany(Recipe::class);
}
/**
* Relations should be share to views.
*
* @return array<int,string>
*/
public static function getPropsRelations(): array
{
return self::getCacheRelations();
}
private static function manageFields(self $model): void
{
self::assertNameIsUnique($model->name, $model->id);
$model->slug = Str::slug($model->name);
$model->picture = FileStorageHelper::storeFile($model, $model->picture, true);
if ($model->pdf) {
$model->pdf = FileStorageHelper::storeFile($model, $model->pdf, true);
}
}
private static function assertNameIsUnique(?string $name, string | int | null $id = null): void
{
$table = new self()->getTable();
ToolboxHelper::assertFieldIsUnique($table, 'name', $name, $id);
ToolboxHelper::assertFieldIsUnique($table, 'slug', Str::slug($name ?? ''), $id);
}
/**
* Get the indexable data array for the model.
*
* @return array<string, mixed>
*/
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'category' => $this->category?->value,
];
}
}

180
app/Models/Recipe.php Normal file
View file

@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Lib\Helpers\FileStorageHelper;
use App\Lib\Helpers\ToolboxHelper;
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\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
use Laravel\Scout\Searchable;
use Ramsey\Uuid\Uuid;
use Spatie\Translatable\HasTranslations;
use Uwil\LaravelBackend\Interfaces\TranslatableModel;
use Uwil\LaravelBackend\Traits\Models\HasActivityLog;
use Uwil\LaravelBackend\Traits\Models\HasCache;
use Uwil\LaravelBackend\Traits\Models\HasModelNaming;
use Uwil\LaravelBackend\Traits\Models\HasUwilImages;
// phpcs:disable Generic.Files.LineLength.TooLong
/**
* @phpcs :disable Generic.Files.LineLength.TooLong
*
* @property string $id
* @property string $seo_title
* @property string $seo_description
* @property string $picture
* @property string $picture_alt
* @property string $picture_title
* @property string $name
* @property string $slug
* @property ProductCategory $product_category Domain\Models\ValueObjects\Products\ProductRange
* @property ConsumptionTime $consomption_time Domain\Models\ValueObjects\Recipes\ConsumptionTime
* @property Inspiration $inspiration Domain\Models\ValueObjects\Recipes\Inspiration
* @property Season $season Domain\Models\ValueObjects\Recipes\Season
* @property string|null $description
* @property string|null $details
* @property string $ingredients_list
* @property string $preparation_steps
* @property string|null $tips_and_advices
* @property \Illuminate\Support\Carbon|null $deleted_at
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Uwil\LaravelBackend\Models\ActivityLog> $activityLogs
* @property-read array $translatable_columns_from
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Product> $products
* @property-read mixed $translations
*
* @method static \Database\Factories\RecipeFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|Recipe onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Recipe whereJsonContainsLocale(string $column, string $locale, ?mixed $value, string $operand = '=')
* @method static \Illuminate\Database\Eloquent\Builder<static>|Recipe whereJsonContainsLocales(string $column, array $locales, ?mixed $value, string $operand = '=')
* @method static \Illuminate\Database\Eloquent\Builder<static>|Recipe whereLocale(string $column, string $locale)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Recipe whereLocales(string $column, array $locales)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Recipe withTrashed(bool $withTrashed = true)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Recipe withoutTrashed()
*
* @mixin \Eloquent
*/
// phpcs:enable Generic.Files.LineLength.TooLong
class Recipe extends Model implements TranslatableModel
{
use HasActivityLog;
use HasCache;
/** @use HasFactory<\Database\Factories\RecipeFactory> */
use HasFactory;
use HasModelNaming;
use HasTranslations;
use HasUuids;
use HasUwilImages;
use Searchable;
use SoftDeletes;
protected $fillable = [
'seo_title',
'seo_description',
'picture',
'picture_alt',
'picture_title',
'name',
'product_category',
'consomption_time',
'inspiration',
'season',
'description',
'details',
'ingredients_list',
'preparation_steps',
'tips_and_advices',
];
protected $casts = [
'product_category' => ProductCategory::class,
'consomption_time' => ConsumptionTime::class,
'inspiration' => Inspiration::class,
'season' => Season::class,
];
public function newUniqueId(): string
{
return Uuid::uuid7()->toString();
}
public function getId(): RecipeId
{
return new RecipeId(Uuid::fromString($this->id));
}
#[\Override]
protected static function booted(): void
{
static::saving(function (self $model): void {
self::manageFields($model);
});
static::deleting(function (self $model): void {
FileStorageHelper::removeFile($model, 'picture');
});
}
/** @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<\App\Models\Product,$this> */
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class);
}
/**
* Relations should be share to views.
*
* @return array<int,string>
*/
public static function getPropsRelations(): array
{
return self::getCacheRelations();
}
private static function manageFields(self $model): void
{
self::assertNameIsUnique($model->name, $model->id);
$model->slug = Str::slug($model->name);
$model->picture = FileStorageHelper::storeFile($model, $model->picture, true);
}
private static function assertNameIsUnique(?string $name, string | int | null $id = null): void
{
$table = new self()->getTable();
ToolboxHelper::assertFieldIsUnique($table, 'name', $name, $id);
ToolboxHelper::assertFieldIsUnique($table, 'slug', Str::slug($name ?? ''), $id);
}
/**
* Get the indexable data array for the model.
*
* @return array<string, mixed>
*/
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'product_category' => $this->product_category?->value,
'consomption_time' => $this->consomption_time?->value,
'inspiration' => $this->inspiration?->value,
'season' => $this->season?->value,
];
}
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Product;
use Uwil\LaravelBackend\Enums\RoleEnum;
use Uwil\LaravelBackend\Models\User;
use Uwil\LaravelBackend\Policies\Rules\UserStaticRules;
class ProductPolicy
{
public function viewAny(User $authModel): bool
{
return UserStaticRules::atLeastRole($authModel, RoleEnum::admin);
}
public function viewTrash(User $authModel): bool
{
return UserStaticRules::atLeastRole($authModel, RoleEnum::admin);
}
public function view(User $authModel, Product $_): bool
{
return UserStaticRules::atLeastRole($authModel, RoleEnum::admin);
}
public function create(User $authModel): bool
{
return UserStaticRules::atLeastRole($authModel, RoleEnum::admin);
}
public function duplicate(User $authModel, Product $_): bool
{
return UserStaticRules::atLeastRole($authModel, RoleEnum::admin);
}
public function update(User $authModel, Product $_): bool
{
return UserStaticRules::atLeastRole($authModel, RoleEnum::admin);
}
public function delete(User $authModel, Product $_): bool
{
return UserStaticRules::atLeastRole($authModel, RoleEnum::admin);
}
public function restore(User $authModel, Product $_): bool
{
return UserStaticRules::atLeastRole($authModel, RoleEnum::admin);
}
public function forceDelete(User $authModel, Product $_): bool
{
return UserStaticRules::atLeastRole($authModel, RoleEnum::admin);
}
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Recipe;
use Uwil\LaravelBackend\Enums\RoleEnum;
use Uwil\LaravelBackend\Models\User;
use Uwil\LaravelBackend\Policies\Rules\UserStaticRules;
class RecipePolicy
{
public function viewAny(User $authModel): bool
{
return UserStaticRules::atLeastRole($authModel, RoleEnum::admin);
}
public function viewTrash(User $authModel): bool
{
return UserStaticRules::atLeastRole($authModel, RoleEnum::admin);
}
public function view(User $authModel, Recipe $_): bool
{
return UserStaticRules::atLeastRole($authModel, RoleEnum::admin);
}
public function create(User $authModel): bool
{
return UserStaticRules::atLeastRole($authModel, RoleEnum::admin);
}
public function duplicate(User $authModel, Recipe $_): bool
{
return UserStaticRules::atLeastRole($authModel, RoleEnum::admin);
}
public function update(User $authModel, Recipe $_): bool
{
return UserStaticRules::atLeastRole($authModel, RoleEnum::admin);
}
public function delete(User $authModel, Recipe $_): bool
{
return UserStaticRules::atLeastRole($authModel, RoleEnum::admin);
}
public function restore(User $authModel, Recipe $_): bool
{
return UserStaticRules::atLeastRole($authModel, RoleEnum::admin);
}
public function forceDelete(User $authModel, Recipe $_): bool
{
return UserStaticRules::atLeastRole($authModel, RoleEnum::admin);
}
}

View file

@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Contracts\Directories\ProductDirectory;
use App\Contracts\Directories\RecipeDirectory;
use App\Directories\EloquentProductDirectory;
use App\Directories\EloquentRecipeDirectory;
use App\Enums\Contact\ActivitySectorEnum;
use App\Enums\Contact\ObjectEnum;
use App\Services\AdminAuthService;
use Domain\Models\ValueObjects\Pagination;
use Domain\Models\ValueObjects\Products\BreedingMethod;
use Domain\Models\ValueObjects\Products\Business;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Domain\Models\ValueObjects\Products\ProductFormat;
use Domain\Models\ValueObjects\Products\Technology;
use Domain\Models\ValueObjects\Recipes\ConsumptionTime;
use Domain\Models\ValueObjects\Recipes\Inspiration;
use Domain\Models\ValueObjects\Recipes\Season;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\ServiceProvider;
use Uwil\LaravelBackend\Support\ModelListRegistry;
use Uwil\LaravelCommon\Support\InertiaSharedRegistry;
use Uwil\LaravelDynamicPages\Enums\PageStatusEnum;
use Uwil\LaravelDynamicPages\Models\DynamicPage;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(
AdminAuthService::class,
function () {
$guard = Auth::guard('backend');
return new AdminAuthService($guard);
}
);
$this->app->singleton(
ProductDirectory::class,
fn () => new EloquentProductDirectory()
);
$this->app->singleton(
RecipeDirectory::class,
fn () => new EloquentRecipeDirectory()
);
$this->requestMacro();
}
public function boot(): void
{
$this->dateSetup();
$this->laravelGlobalSetup();
$this->registerSharedInertiaProps();
$this->registerModelsInBackendRegistry();
}
private function laravelGlobalSetup(): void
{
Model::shouldBeStrict(app()->environment('local'));
Vite::useCspNonce();
URL::forceScheme('https');
}
private function registerSharedInertiaProps(): void
{
InertiaSharedRegistry::add('dynamicPages', function (): array {
if (! Schema::hasTable('dynamic_pages')) {
return [];
}
$dynamicPageModels = DynamicPage::getModelsCached()
->where('status', PageStatusEnum::published);
$dynamicPages = [];
foreach ($dynamicPageModels as $dynamicPageModel) {
$pascalCase = str_replace(' ', '', ucwords(str_replace('-', ' ', $dynamicPageModel->id_key)));
$dynamicPages["page{$pascalCase}"] = $dynamicPageModel;
}
return $dynamicPages;
});
InertiaSharedRegistry::add('activitySectorEnum', fn () => ActivitySectorEnum::toArray());
InertiaSharedRegistry::add('objectEnum', fn () => ObjectEnum::toArray());
InertiaSharedRegistry::add('breedingMethodEnum', fn () => BreedingMethod::toArray());
InertiaSharedRegistry::add('businessEnum', fn () => Business::toArray());
InertiaSharedRegistry::add('productCategoryEnum', fn () => ProductCategory::toArray());
InertiaSharedRegistry::add('productFormatEnum', fn () => ProductFormat::toArray());
InertiaSharedRegistry::add('technologyEnum', fn () => Technology::toArray());
InertiaSharedRegistry::add('consumptionTimeEnum', fn () => ConsumptionTime::toArray());
InertiaSharedRegistry::add('inspirationEnum', fn () => Inspiration::toArray());
InertiaSharedRegistry::add('seasonEnum', fn () => Season::toArray());
}
private function registerModelsInBackendRegistry(): void
{
ModelListRegistry::add(
2,
'products',
trans_choice('models.classes.product', 1),
'name',
);
ModelListRegistry::add(
3,
'recipes',
trans_choice('models.classes.recipe', 1),
'name',
);
}
private function dateSetup(): void
{
foreach ([\LC_TIME, \LC_MESSAGES, \LC_COLLATE, \LC_CTYPE, \LC_MONETARY] as $constant) {
setlocale(
$constant,
sprintf(
'%s_%s.UTF-8',
Config::string('app.locale'),
strtoupper(Config::string('app.locale'))
)
);
}
Date::setLocale(Config::string('app.locale'));
Date::setFallbackLocale(Config::string('app.fallback_locale'));
}
private function requestMacro(): void
{
Request::macro('pagination', function (int $defaultPageSize = 25): Pagination {
/** @var \Illuminate\Http\Request $this */
if ($this->has('offset') || $this->has('limit')) {
return Pagination::ofOffsetAndLimit(
$this->integer('offset', 0),
$this->integer('limit', $defaultPageSize)
);
}
return Pagination::ofPage(
$this->integer('page', 1),
$this->integer(
'per_page',
// * Default laravel paginator name
$this->integer('pagination', $defaultPageSize)
)
);
});
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Models\Product;
use App\Models\Recipe;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Uwil\LaravelCommon\Support\InertiaSharedRegistry;
class AuthServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
InertiaSharedRegistry::add('auth.policies', function () {
$localModels = [
Product::class,
Recipe::class,
];
$localPolicies = [];
foreach ($localModels as $model) {
$key = Str::camel(Str::plural(class_basename($model)));
$localPolicies[$key] = [
'viewAny' => Gate::allows('viewAny', $model),
'create' => Gate::allows('create', $model),
'viewTrash' => Gate::allows('viewTrash', $model),
];
}
return $localPolicies;
});
}
}

View file

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Contracts\Directories\ProductDirectory;
use App\Contracts\Directories\RecipeDirectory;
use App\Models\Product;
use App\Models\Recipe;
use Domain\Models\Entities\Product as DomainProduct;
use Domain\Models\Entities\Recipe as DomainRecipe;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Models\ValueObjects\Entities\RecipeId;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Ramsey\Uuid\Uuid;
use Shared\Helpers\UuidHelper;
class RouteServiceProvider extends ServiceProvider
{
/**
* Define your route model bindings, pattern filters, and other route configuration.
*/
public function boot(): void
{
Route::bind('product', function (string $value) {
if (request()->is('back/*') || request()->is('back') || request()->routeIs('back.*')) {
return Product::withTrashed()
->where('id', $value)
->orWhere('slug', $value)
->firstOrFail();
}
return $this->resolveProduct($value);
});
Route::bind('recipe', function (string $value) {
if (request()->is('back/*') || request()->is('back') || request()->routeIs('back.*')) {
return Recipe::withTrashed()
->where('id', $value)
->orWhere('slug', $value)
->firstOrFail();
}
return $this->resolveRecipe($value);
});
}
private function resolveProduct(string $value): DomainProduct
{
$productDirectory = app(ProductDirectory::class);
if (Str::isUuid($value) && UuidHelper::isV7(Uuid::fromString($value))) {
$product = $productDirectory->find(new ProductId(Uuid::fromString($value)));
}
if (! isset($product)) {
$product = $productDirectory->findBySlug($value);
}
if (! isset($product)) {
throw new ModelNotFoundException('Product not found');
}
return $product;
}
private function resolveRecipe(string $value): DomainRecipe
{
$recipeDirectory = app(RecipeDirectory::class);
if (Str::isUuid($value) && UuidHelper::isV7(Uuid::fromString($value))) {
$recipe = $recipeDirectory->find(new RecipeId(Uuid::fromString($value)));
}
if (! isset($recipe)) {
$recipe = $recipeDirectory->findBySlug($value);
}
if (! isset($recipe)) {
throw new ModelNotFoundException('Recipe not found');
}
return $recipe;
}
}

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Contracts\View\View as ContractsView;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\ServiceProvider;
class ViewServiceProvider extends ServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
return;
}
View::composer('*', function (ContractsView $view) {
$view->with('csp_nonce', Vite::cspNonce());
});
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Exceptions\AuthServiceException;
use Illuminate\Contracts\Auth\StatefulGuard;
use Uwil\LaravelBackend\Models\User;
class AdminAuthService
{
public function __construct(
private readonly StatefulGuard $guard
) {
}
public function canWithUser(\UnitEnum | string $abilities, mixed $arguments = []): bool
{
return $this->getUser()?->can($abilities, $arguments) ?? false;
}
public function check(): bool
{
return $this->guard->check();
}
public function guest(): bool
{
return ! $this->check();
}
public function getUser(): ?User
{
$user = $this->guard->user();
if ($user && ! ($user instanceof User)) {
throw new \InvalidArgumentException('Auth service needs a backend guard');
}
return $user ? clone $user : null;
}
public function getUserAsMandatory(): User
{
$user = $this->getUser();
if ($user === null) {
throw new AuthServiceException('Authenticated user is required from here');
}
return clone $user;
}
public function getMandatoryUserId(): string
{
return (string) $this->getUserAsMandatory()->id;
}
}

18
artisan Normal file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

13
bootstrap/app.php Normal file
View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Application;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
health: '/up',
)
->withProviders(require __DIR__ . '/providers.php')
->withMiddleware(require __DIR__ . '/middlewares.php')
->withExceptions(require __DIR__ . '/exceptions.php')->create();

2
bootstrap/cache/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

45
bootstrap/exceptions.php Normal file
View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Uwil\LaravelCommon\Helpers\InertiaHelper;
return function (Exceptions $exceptions) {
$exceptions->render(function (HttpException $e, Request $request) {
$status = $e->getStatusCode();
if ($request->isAttemptingPrecognition() && $status === 204) {
return response('', 204, $e->getHeaders());
}
return InertiaHelper::errorRender($request, $status);
});
$exceptions->report(function (\Throwable $e) {
if (app()->bound('sentry')) {
app('sentry')->captureException($e);
}
});
$exceptions->render(function (ValidationException $e, Request $request) {
if ($request->wantsJson() || $request->isAttemptingPrecognition()) {
return response()->json([
'message' => $e->getMessage(),
'errors' => $e->errors(),
], $e->status);
}
$safeInput = Collection::make($request->all())
->except(['password', 'password_confirmation', 'current_password'])
->toArray();
return back()
->withInput($safeInput)
->withErrors($e->errors());
});
};

89
bootstrap/middlewares.php Normal file
View file

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
use App\Http\Middlewares\HtmlSanitizer;
use App\Http\Middlewares\SecureHeaders;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
use Illuminate\Http\Request;
use Illuminate\Session\Middleware\StartSession;
use Uwil\AdminSecure\Laravel\Http\Middleware\AdminSecureMiddleware;
use Uwil\LaravelBackend\Middlewares\Authenticate;
use Uwil\LaravelBackend\Middlewares\EncryptCookies;
use Uwil\LaravelBackend\Middlewares\LogoutUnpublishedUsers;
use Uwil\LaravelBackend\Middlewares\RedirectIfAuthenticated;
use Uwil\LaravelCommon\Middlewares\DisableSsr;
use Uwil\LaravelCommon\Middlewares\ForceLocale;
use Uwil\LaravelCommon\Middlewares\HandleInertiaRequests;
use Uwil\LaravelCommon\Middlewares\TrimStrings;
use Uwil\LaravelCommon\Middlewares\TrustHosts;
use Uwil\LaravelCommon\Middlewares\TrustProxies;
$redirectGuests = function (Request $request) {
$logins = [
'backend' => route('back.login'),
'frontend' => '/',
];
foreach ($request->route()?->gatherMiddleware() ?? [] as $middleware) {
if (\is_string($middleware) && str_contains($middleware, 'auth:')) {
$route = $logins[str_replace('auth:', '', $middleware)] ?? null;
if ($route) {
return $route;
}
}
}
return $logins['frontend'];
};
$redirectUsers = function (Request $request) {
$logins = [
'backend' => route('back.dashboard.index'),
'frontend' => '/',
];
foreach ($request->route()?->gatherMiddleware() ?? [] as $middleware) {
if (\is_string($middleware) && str_contains($middleware, 'auth:')) {
$route = $logins[str_replace('auth:', '', $middleware)] ?? null;
if ($route) {
return $route;
}
}
}
return $logins['frontend'];
};
return function (Middleware $middleware) use ($redirectGuests, $redirectUsers): void {
$middleware->trustProxies(at: '*');
$middleware->redirectGuestsTo($redirectGuests);
$middleware->redirectUsersTo($redirectUsers);
$middleware->web(append: [
ForceLocale::class,
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
SecureHeaders::class,
TrimStrings::class,
TrustHosts::class,
TrustProxies::class,
LogoutUnpublishedUsers::class,
]);
$middleware->preventRequestForgery(except: [
'/csp-report',
]);
$middleware->group('saml', [
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
]);
$middleware->alias([
'auth' => Authenticate::class,
'guest' => RedirectIfAuthenticated::class,
'htmlsanitize' => HtmlSanitizer::class,
'admin-secure' => AdminSecureMiddleware::class,
'disable-ssr' => DisableSsr::class,
]);
};

27
bootstrap/providers.php Normal file
View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use App\Providers\AppServiceProvider;
use App\Providers\AuthServiceProvider;
use App\Providers\RouteServiceProvider;
use App\Providers\ViewServiceProvider;
use Uwil\AdminSecure\Laravel\AdminSecureServiceProvider;
use Uwil\LaravelBackend\BackendServiceProvider;
use Uwil\LaravelCommon\CommonServiceProvider;
use Uwil\LaravelDynamicPages\DynamicPagesServiceProvider;
use Uwil\LaravelK8S\K8SServiceProvider;
use Uwil\LaravelSchemaOrg\SchemaOrgServiceProvider;
return [
AppServiceProvider::class,
AuthServiceProvider::class,
RouteServiceProvider::class,
ViewServiceProvider::class,
AdminSecureServiceProvider::class,
BackendServiceProvider::class,
CommonServiceProvider::class,
DynamicPagesServiceProvider::class,
K8SServiceProvider::class,
SchemaOrgServiceProvider::class,
];

1
commitlint.config.js Normal file
View file

@ -0,0 +1 @@
export default { extends: ["@uwil/husky-config/commitlint.config.js"] };

21
components.json Normal file
View file

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "resources/sass/common.scss",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

124
composer.json Normal file
View file

@ -0,0 +1,124 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "uwil/laravel-project",
"type": "project",
"description": "UWIL Laravel project",
"keywords": [
"laravel",
"framework"
],
"license": "MIT",
"require": {
"php": "^8.5",
"scaler-tech/laravel-saml2": "^2.5",
"diglactic/laravel-breadcrumbs": "^10.0",
"inertiajs/inertia-laravel": "^3.0",
"laravel/framework": "^13.0",
"laravel/scout": "^11.3",
"laravel/telescope": "^5.7",
"laravel/tinker": "^3.0",
"meilisearch/meilisearch-php": "^1.16",
"monolog/monolog": "^3.0",
"psr/log": "^3.0",
"sentry/sentry-laravel": "^4.25",
"tightenco/ziggy": "^2.5",
"uwil/k8s-laravel": "^3.0",
"uwil/admin-secure-laravel": "^6.0",
"uwil/laravel-dynamic-pages": "^5.0"
},
"require-dev": {
"barryvdh/laravel-ide-helper": "^3.7",
"fakerphp/faker": "^1.23",
"larastan/larastan": "^3.4",
"laravel-lang/common": "^6.8",
"laravel/breeze": "^2.3.0",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.29",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.9",
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpunit/phpunit": "^12.0",
"slevomat/coding-standard": "^8.22"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/",
"Domain\\": "domain/",
"Infrastructure\\": "infra/",
"Shared\\": "shared/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
"Illuminate\\Foundation\\ComposerScripts::postUpdate",
"@php artisan lang:update",
"@php artisan ide-helper:generate",
"@php artisan ide-helper:models -RW",
"./vendor/bin/pint app/Models"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true,
"dealerdirect/phpcodesniffer-composer-installer": true
},
"platform-check": false
},
"minimum-stability": "stable",
"prefer-stable": true,
"repositories": [
{
"name": "lab.s-web.fr",
"type": "composer",
"url": "https://lab.s-web.fr/api/v4/group/23/-/packages/composer/packages.json"
}
]
}

13414
composer.lock generated Normal file

File diff suppressed because it is too large Load diff

112
config/app.php Normal file
View file

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
return [
/**
*--------------------------------------------------------------------------
* Application Name
*--------------------------------------------------------------------------
*
* This value is the name of your application, which will be used when the
* framework needs to place the application's name in a notification or
* other UI elements where an application name needs to be displayed.
*/
'name' => env('APP_NAME', 'Laravel'),
/**
*--------------------------------------------------------------------------
* Application Environment
*--------------------------------------------------------------------------
*
* This value determines the "environment" your application is currently
* running in. This may determine how you prefer to configure various
* services the application utilizes. Set this in your ".env" file.
*/
'env' => env('APP_ENV', 'production'),
/**
*--------------------------------------------------------------------------
* Application Debug Mode
*--------------------------------------------------------------------------
*
* When your application is in debug mode, detailed error messages with
* stack traces will be shown on every error that occurs within your
* application. If disabled, a simple generic error page is shown.
*/
'debug' => (bool) env('APP_DEBUG', false),
/**
*--------------------------------------------------------------------------
* Application URL
*--------------------------------------------------------------------------
*
* This URL is used by the console to properly generate URLs when using
* the Artisan command line tool. You should set this to the root of
* the application so that it's available within Artisan commands.
*/
'url' => env('APP_URL', 'http://localhost'),
/**
*--------------------------------------------------------------------------
* Application Timezone
*--------------------------------------------------------------------------
*
* Here you may specify the default timezone for your application, which
* will be used by the PHP date and date-time functions. The timezone
* is set to "UTC" by default as it is suitable for most use cases.
*/
'timezone' => env('APP_TIMEZONE', 'Europe/Paris'),
/**
*--------------------------------------------------------------------------
* Application Locale Configuration
*--------------------------------------------------------------------------
*
* The application locale determines the default locale that will be used
* by Laravel's translation / localization methods. This option can be
* set to any locale for which you plan to have translation strings.
*/
'locale' => env('APP_LOCALE', 'fr'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'fr'),
'faker_locale' => env('APP_FAKER_LOCALE', 'fr_FR'),
/**
*--------------------------------------------------------------------------
* Encryption Key
*--------------------------------------------------------------------------
*
* This key is utilized by Laravel's encryption services and should be set
* to a random, 32 character string to ensure that all encrypted values
* are secure. You should do this prior to deploying the application.
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', env('APP_PREVIOUS_KEYS', ''))
),
],
/**
*--------------------------------------------------------------------------
* Maintenance Mode Driver
*--------------------------------------------------------------------------
*
* These configuration options determine the driver used to determine and
* manage Laravel's "maintenance mode" status. The "cache" driver will
* allow maintenance mode to be controlled across multiple machines.
*
* Supported drivers: "file", "cache"
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

109
config/auth.php Normal file
View file

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
use Uwil\LaravelBackend\Models\User;
return [
/**
*--------------------------------------------------------------------------
* Authentication Defaults
*--------------------------------------------------------------------------
*
* This option defines the default authentication "guard" and password
* reset "broker" for your application. You may change these values
* as required, but they're a perfect start for most applications.
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/**
*--------------------------------------------------------------------------
* Authentication Guards
*--------------------------------------------------------------------------
*
* Next, you may define every authentication guard for your application.
* Of course, a great default configuration has been defined for you
* which utilizes session storage plus the Eloquent user provider.
*
* All authentication guards have a user provider, which defines how the
* users are actually retrieved out of your database or other storage
* system used by the application. Typically, Eloquent is utilized.
*
* Supported: "session"
*/
'guards' => [
'backend' => [
'driver' => 'session',
'provider' => 'users',
],
'frontend' => [
'driver' => 'session',
'provider' => 'customers',
],
],
/**
*--------------------------------------------------------------------------
* User Providers
*--------------------------------------------------------------------------
*
* All authentication guards have a user provider, which defines how the
* users are actually retrieved out of your database or other storage
* system used by the application. Typically, Eloquent is utilized.
*
* If you have multiple user tables or models you may configure multiple
* providers to represent the model / table. These providers may then
* be assigned to any extra authentication guards you have defined.
*
* Supported: "database", "eloquent"
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
],
/**
*--------------------------------------------------------------------------
* Resetting Passwords
*--------------------------------------------------------------------------
*
* These configuration options specify the behavior of Laravel's password
* reset functionality, including the table utilized for token storage
* and the user provider that is invoked to actually retrieve users.
*
* The expiry time is the number of minutes that each reset token will be
* considered valid. This security feature keeps tokens short-lived so
* they have less time to be guessed. You may change this as needed.
*
* The throttle setting is the number of seconds a user must wait before
* generating more password reset tokens. This prevents the user from
* quickly generating a very large amount of password reset tokens.
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/**
*--------------------------------------------------------------------------
* Password Confirmation Timeout
*--------------------------------------------------------------------------
*
* Here you may define the amount of seconds before a password confirmation
* window expires and users are asked to re-enter their password via the
* confirmation screen. By default, the timeout lasts for three hours.
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

76
config/breadcrumbs.php Normal file
View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
use Diglactic\Breadcrumbs\Generator;
use Diglactic\Breadcrumbs\Manager;
return [
/**
*--------------------------------------------------------------------------
* View Name
*--------------------------------------------------------------------------
*
* Choose a view to display when Breadcrumbs::render() is called.
* Built in templates are:
*
* - 'breadcrumbs::bootstrap5' - Bootstrap 5
* - 'breadcrumbs::bootstrap4' - Bootstrap 4
* - 'breadcrumbs::bulma' - Bulma
* - 'breadcrumbs::foundation6' - Foundation 6
* - 'breadcrumbs::json-ld' - JSON-LD Structured Data
* - 'breadcrumbs::materialize' - Materialize
* - 'breadcrumbs::tailwind' - Tailwind CSS
* - 'breadcrumbs::uikit' - UIkit
*
* Or a custom view, e.g. '_partials/breadcrumbs'.
*/
'view' => null,
/**
*--------------------------------------------------------------------------
* Breadcrumbs File(s)
*--------------------------------------------------------------------------
*
* The file(s) where breadcrumbs are defined. e.g.
*
* - base_path('routes/breadcrumbs.php')
* - glob(base_path('breadcrumbs/*.php'))
*/
'files' => array_filter([
...glob(base_path('routes/breadcrumbs/*.php')) ?: [],
]),
/**
*--------------------------------------------------------------------------
* Exceptions
*--------------------------------------------------------------------------
*
* Determine when to throw an exception.
*/
// * When route-bound breadcrumbs are used but the current route doesn't have a name (UnnamedRouteException)
'unnamed-route-exception' => true,
// * When route-bound breadcrumbs are used and the matching breadcrumb doesn't exist (InvalidBreadcrumbException)
'missing-route-bound-breadcrumb-exception' => true,
// * When a named breadcrumb is used but doesn't exist (InvalidBreadcrumbException)
'invalid-named-breadcrumb-exception' => true,
/**
*--------------------------------------------------------------------------
* Classes
*--------------------------------------------------------------------------
*
* Subclass the default classes for more advanced customisations.
*/
// * Manager
'manager-class' => Manager::class,
// * Generator
'generator-class' => Generator::class,
];

117
config/cache.php Normal file
View file

@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Str;
return [
/*
*--------------------------------------------------------------------------
* Default Cache Store
*--------------------------------------------------------------------------
*
* This option controls the default cache store that will be used by the
* framework. This connection is utilized if another isn't explicitly
* specified when running a cache operation inside the application.
*
*/
'default' => env('CACHE_STORE', 'database'),
/*
*--------------------------------------------------------------------------
* Cache Stores
*--------------------------------------------------------------------------
*
* Here you may define all of the cache "stores" for your application as
* well as their drivers. You may even define multiple stores for the
* same cache driver to group types of items stored in your caches.
*
* Supported drivers: "array", "database", "file", "memcached",
* "redis", "dynamodb", "octane",
* "failover", "null"
*
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
*--------------------------------------------------------------------------
* Cache Key Prefix
*--------------------------------------------------------------------------
*
* When utilizing the APC, database, memcached, Redis, and DynamoDB cache
* stores, there might be other applications using the same cache. For
* that reason, you may prefix every cache key to avoid collisions.
*
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')) . '-cache-'),
];

21
config/cookie-notice.php Normal file
View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
return [
'google' => [
'recaptcha' => [
'public' => env('GOOGLE_RECAPTCHA_PUBLIC'),
'private' => env('GOOGLE_RECAPTCHA_PRIVATE'),
'version' => env('GOOGLE_RECAPTCHA_VERSION', '3') === '3' ? true : false,
'bypassKey' => 'bypass',
],
'analytics' => [
'id' => env('GOOGLE_ANALYTICS_ID'),
],
],
'matomo' => [
'site_id' => env('MATOMO_SITE_ID'),
],
];

190
config/database.php Normal file
View file

@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
*--------------------------------------------------------------------------
* Default Database Connection Name
*--------------------------------------------------------------------------
*
* Here you may specify which of the database connections below you wish
* to use as your default connection for database operations. This is
* the connection which will be utilized unless another connection
* is explicitly specified when you execute a query / statement.
*
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
*--------------------------------------------------------------------------
* Database Connections
*--------------------------------------------------------------------------
*
* Below are all of the database connections defined for your application.
* An example configuration is provided for each database system which
* is supported by Laravel. You're free to add / remove connections.
*
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500
? Mysql::ATTR_SSL_CA
: \PDO::MYSQL_ATTR_SSL_CA
) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500
? Mysql::ATTR_SSL_CA
: \PDO::MYSQL_ATTR_SSL_CA
) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
],
],
/*
*--------------------------------------------------------------------------
* Migration Repository Table
*--------------------------------------------------------------------------
*
* This table keeps track of all the migrations that have already run for
* your application. Using this information, we can determine which of
* the migrations on disk haven't actually been run on the database.
*
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
*--------------------------------------------------------------------------
* Redis Databases
*--------------------------------------------------------------------------
*
* Redis is an open source, fast, and advanced key-value store that also
* provides a richer body of commands than a typical key-value system
* such as Memcached. You may define your connection settings here.
*
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')) . '-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];

76
config/filesystems.php Normal file
View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
return [
/**
*--------------------------------------------------------------------------
* Default Filesystem Disk
*--------------------------------------------------------------------------
*
* Here you may specify the default filesystem disk that should be used
* by the framework. The "local" disk, as well as a variety of cloud
* based disks are available to your application for file storage.
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/**
*--------------------------------------------------------------------------
* Filesystem Disks
*--------------------------------------------------------------------------
*
* Below you may configure as many filesystem disks as necessary, and you
* may even configure multiple disks for the same driver. Examples for
* most supported storage drivers are configured here for reference.
*
* Supported drivers: "local", "ftp", "sftp", "s3"
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL') . '/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/**
*--------------------------------------------------------------------------
* Symbolic Links
*--------------------------------------------------------------------------
*
* Here you may configure the symbolic links that will be created when the
* `storage:link` Artisan command is executed. The array keys should be
* the locations of the links and the values should be their targets.
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

379
config/ide-helper.php Normal file
View file

@ -0,0 +1,379 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Client\Factory;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Session\Store;
return [
/*
|--------------------------------------------------------------------------
| Filename
|--------------------------------------------------------------------------
|
| The default filename.
|
*/
'filename' => '_ide_helper.php',
/*
|--------------------------------------------------------------------------
| Models filename
|--------------------------------------------------------------------------
|
| The default filename for the models helper file.
|
*/
'models_filename' => '_ide_helper_models.php',
/*
|--------------------------------------------------------------------------
| PhpStorm meta filename
|--------------------------------------------------------------------------
|
| PhpStorm also supports the directory `.phpstorm.meta.php/` with arbitrary
| files in it, should you need additional files for your project; e.g.
| `.phpstorm.meta.php/laravel_ide_Helper.php'.
|
*/
'meta_filename' => '.phpstorm.meta.php',
/*
|--------------------------------------------------------------------------
| Fluent helpers
|--------------------------------------------------------------------------
|
| Set to true to generate commonly used Fluent methods.
|
*/
'include_fluent' => false,
/*
|--------------------------------------------------------------------------
| Write model query methods
|--------------------------------------------------------------------------
|
| Set to false to disable generated docs for the 'query()', 'newQuery()' and 'newModelQuery()' methods.
|
*/
'write_query_methods' => false,
/*
|--------------------------------------------------------------------------
| Write model magic methods
|--------------------------------------------------------------------------
|
| Set to false to disable write magic methods of model.
|
*/
'write_model_magic_where' => false,
/*
|--------------------------------------------------------------------------
| Write model external Eloquent builder methods
|--------------------------------------------------------------------------
|
| Set to false to disable write external Eloquent builder methods.
|
*/
'write_model_external_builder_methods' => false,
/*
|--------------------------------------------------------------------------
| Write model relation count and exists properties
|--------------------------------------------------------------------------
|
| Set to false to disable writing of relation count and exists properties
| to model DocBlocks.
|
*/
'write_model_relation_count_properties' => false,
'write_model_relation_exists_properties' => false,
/*
|--------------------------------------------------------------------------
| Write Eloquent model mixins
|--------------------------------------------------------------------------
|
| This will add the necessary DocBlock mixins to the model class
| contained in the Laravel framework. This helps the IDE with
| auto-completion.
|
| Please be aware that this setting changes a file within the /vendor directory.
|
*/
'write_eloquent_model_mixins' => false,
/*
|--------------------------------------------------------------------------
| Helper files to include
|--------------------------------------------------------------------------
|
| Include helper files. By default not included, but can be toggled with the
| -- helpers (-H) option. Extra helper files can be included.
|
*/
'include_helpers' => false,
'helper_files' => [
base_path() . '/vendor/laravel/framework/src/Illuminate/Support/helpers.php',
base_path() . '/vendor/laravel/framework/src/Illuminate/Foundation/helpers.php',
],
/*
|--------------------------------------------------------------------------
| Model locations to include
|--------------------------------------------------------------------------
|
| Define in which directories the ide-helper:models command should look
| for models.
|
| glob patterns are supported to easier reach models in sub-directories,
| e.g. `app/Services/* /Models` (without the space).
|
*/
'model_locations' => [
'app',
],
/*
|--------------------------------------------------------------------------
| Models to ignore
|--------------------------------------------------------------------------
|
| Define which models should be ignored.
|
*/
'ignored_models' => [],
/*
|--------------------------------------------------------------------------
| Models hooks
|--------------------------------------------------------------------------
|
| Define which hook classes you want to run for models to add custom information.
|
| Hooks should implement Barryvdh\LaravelIdeHelper\Contracts\ModelHookInterface.
|
*/
'model_hooks' => [
// App\Support\IdeHelper\MyModelHook::class
],
/*
|--------------------------------------------------------------------------
| Extra classes
|--------------------------------------------------------------------------
|
| These implementations are not really extended, but called with magic functions.
|
*/
'extra' => [
'Eloquent' => [Builder::class, \Illuminate\Database\Query\Builder::class],
'Session' => [Store::class],
],
'magic' => [],
/*
|--------------------------------------------------------------------------
| Interface implementations
|--------------------------------------------------------------------------
|
| These interfaces will be replaced with the implementing class. Some interfaces
| are detected by the helpers, others can be listed below.
|
*/
'interfaces' => [
// App\MyInterface::class => App\MyImplementation::class,
],
/*
|--------------------------------------------------------------------------
| Support for camel cased models
|--------------------------------------------------------------------------
|
| There are some Laravel packages (such as Eloquence) that allow for accessing
| Eloquent model properties via camel case, instead of snake case.
|
| Enabling this option will support these packages by saving all model
| properties as camel case, instead of snake case.
|
| For example, normally you would see this:
|
| * @property \Illuminate\Support\Carbon $created_at
| * @property \Illuminate\Support\Carbon $updated_at
|
| With this enabled, the properties will be this:
|
| * @property \Illuminate\Support\Carbon $createdAt
| * @property \Illuminate\Support\Carbon $updatedAt
|
| Note, it is currently an all-or-nothing option.
|
*/
'model_camel_case_properties' => false,
/*
|--------------------------------------------------------------------------
| Property casts
|--------------------------------------------------------------------------
|
| Cast the given "real type" to the given "type".
|
*/
'type_overrides' => [
'integer' => 'int',
'boolean' => 'bool',
],
/*
|--------------------------------------------------------------------------
| Include DocBlocks from classes
|--------------------------------------------------------------------------
|
| Include DocBlocks from classes to allow additional code inspection for
| magic methods and properties.
|
*/
'include_class_docblocks' => false,
/*
|--------------------------------------------------------------------------
| Force FQN usage
|--------------------------------------------------------------------------
|
| Use the fully qualified (class) name in DocBlocks,
| even if the class exists in the same namespace
| or there is an import (use className) of the class.
|
*/
'force_fqn' => false,
/*
|--------------------------------------------------------------------------
| Use generics syntax
|--------------------------------------------------------------------------
|
| Use generics syntax within DocBlocks,
| e.g. `Collection<User>` instead of `Collection|User[]`.
|
*/
'use_generics_annotations' => true,
/*
|--------------------------------------------------------------------------
| Default return types for macros
|--------------------------------------------------------------------------
|
| Define default return types for macros without explicit return types.
| e.g. `\Illuminate\Database\Query\Builder::class => 'static'`,
| `\Illuminate\Support\Str::class => 'string'`
|
*/
'macro_default_return_types' => [
Factory::class => PendingRequest::class,
],
/*
|--------------------------------------------------------------------------
| Additional relation types
|--------------------------------------------------------------------------
|
| Sometimes it's needed to create custom relation types. The key of the array
| is the relationship method name. The value of the array is the fully-qualified
| class name of the relationship, e.g. `'relationName' => RelationShipClass::class`.
|
*/
'additional_relation_types' => [],
/*
|--------------------------------------------------------------------------
| Additional relation return types
|--------------------------------------------------------------------------
|
| When using custom relation types its possible for the class name to not contain
| the proper return type of the relation. The key of the array is the relationship
| method name. The value of the array is the return type of the relation ('many'
| or 'morphTo').
| e.g. `'relationName' => 'many'`.
|
*/
'additional_relation_return_types' => [],
/*
|--------------------------------------------------------------------------
| Enforce nullable Eloquent relationships on not null columns
|--------------------------------------------------------------------------
|
| When set to true (default), this option enforces nullable Eloquent relationships.
| However, in cases where the application logic ensures the presence of related
| records it may be desirable to set this option to false to avoid unwanted null warnings.
|
| Default: true
| A not null column with no foreign key constraint will have a "nullable" relationship.
| * @property int $not_null_column_with_no_foreign_key_constraint
| * @property-read BelongsToVariation|null $notNullColumnWithNoForeignKeyConstraint
|
| Option: false
| A not null column with no foreign key constraint will have a "not nullable" relationship.
| * @property int $not_null_column_with_no_foreign_key_constraint
| * @property-read BelongsToVariation $notNullColumnWithNoForeignKeyConstraint
|
*/
'enforce_nullable_relationships' => true,
/*
|--------------------------------------------------------------------------
| Make soft deletable relations nullable
|--------------------------------------------------------------------------
|
| When set to true (default), relationships to models using SoftDeletes trait
| will be marked as nullable. This is because soft-deleted records are excluded
| from queries by default, meaning even non-nullable foreign keys can return
| null when the related model is soft-deleted.
|
| Default: true
| A relationship to a soft-deletable model will include |null in the type:
| * @property-read Team|null $team
|
| Option: false
| A relationship to a soft-deletable model will NOT include |null (unless
| nullable for other reasons such as nullable foreign key column):
| * @property-read Team $team
|
*/
'soft_deletes_force_nullable' => true,
/*
|--------------------------------------------------------------------------
| Run artisan commands after migrations to generate model helpers
|--------------------------------------------------------------------------
|
| The specified commands should run after migrations are finished running.
|
*/
'post_migrate' => [
// 'ide-helper:models --nowrite',
],
];

62
config/inertia.php Normal file
View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
return [
/**
*--------------------------------------------------------------------------
* Server Side Rendering
*--------------------------------------------------------------------------
*
* These options configures if and how Inertia uses Server Side Rendering
* to pre-render the initial visits made to your application's pages.
*
* You can specify a custom SSR bundle path, or omit it to let Inertia
* try and automatically detect it for you.
*
* Do note that enabling these options will NOT automatically make SSR work,
* as a separate rendering service needs to be available. To learn more,
* please visit https://inertiajs.com/server-side-rendering
*/
'ssr' => [
'enabled' => false,
'url' => env('APP_URL', 'https://localhost'),
],
/**
*--------------------------------------------------------------------------
* Testing
*--------------------------------------------------------------------------
*
* The values described here are used to locate Inertia components on the
* filesystem. For instance, when using `assertInertia`, the assertion
* attempts to locate the component as a file relative to any of the
* paths AND with any of the extensions specified here.
*/
'testing' => [
'ensure_pages_exist' => true,
'page_paths' => [
resource_path('ts/front/Pages'),
],
'page_extensions' => [
'js',
'jsx',
'svelte',
'ts',
'tsx',
'vue',
],
],
'history' => [
'encrypt' => false,
],
];

View file

@ -0,0 +1,356 @@
<?php
declare(strict_types=1);
return [
// Uncomment the languages that your site supports - or add new ones.
// These are sorted by the native name, which is the order you might show them in a language selector.
// Regional languages are sorted by their base language, so "British English" sorts as "English, British".
// Original config here: https://github.com/mcamara/laravel-localization/blob/master/src/config/config.php.
// @phpcs:disable Squiz.Commenting.InlineComment.NoSpaceBefore, Generic.Files.LineLength.TooLong, Squiz.PHP.CommentedOutCode.Found
'supportedLocales' => [
// 'ace' => ['name' => 'Achinese', 'script' => 'Latn', 'native' => 'Aceh', 'regional' => ''],
// 'af' => ['name' => 'Afrikaans', 'script' => 'Latn', 'native' => 'Afrikaans', 'regional' => 'af_ZA'],
// 'agq' => ['name' => 'Aghem', 'script' => 'Latn', 'native' => 'Aghem', 'regional' => ''],
// 'ak' => ['name' => 'Akan', 'script' => 'Latn', 'native' => 'Akan', 'regional' => 'ak_GH'],
// 'an' => ['name' => 'Aragonese', 'script' => 'Latn', 'native' => 'aragonés', 'regional' => 'an_ES'],
// 'cch' => ['name' => 'Atsam', 'script' => 'Latn', 'native' => 'Atsam', 'regional' => ''],
// 'gn' => ['name' => 'Guaraní', 'script' => 'Latn', 'native' => 'Avañe’ẽ', 'regional' => ''],
// 'ae' => ['name' => 'Avestan', 'script' => 'Latn', 'native' => 'avesta', 'regional' => ''],
// 'ay' => ['name' => 'Aymara', 'script' => 'Latn', 'native' => 'aymar aru', 'regional' => 'ay_PE'],
// 'az' => ['name' => 'Azerbaijani (Latin)', 'script' => 'Latn', 'native' => 'azərbaycanca', 'regional' => 'az_AZ'],
// 'id' => ['name' => 'Indonesian', 'script' => 'Latn', 'native' => 'Bahasa Indonesia', 'regional' => 'id_ID'],
// 'ms' => ['name' => 'Malay', 'script' => 'Latn', 'native' => 'Bahasa Melayu', 'regional' => 'ms_MY'],
// 'bm' => ['name' => 'Bambara', 'script' => 'Latn', 'native' => 'bamanakan', 'regional' => ''],
// 'jv' => ['name' => 'Javanese (Latin)', 'script' => 'Latn', 'native' => 'Basa Jawa', 'regional' => ''],
// 'su' => ['name' => 'Sundanese', 'script' => 'Latn', 'native' => 'Basa Sunda', 'regional' => ''],
// 'bh' => ['name' => 'Bihari', 'script' => 'Latn', 'native' => 'Bihari', 'regional' => ''],
// 'bi' => ['name' => 'Bislama', 'script' => 'Latn', 'native' => 'Bislama', 'regional' => ''],
// 'nb' => ['name' => 'Norwegian Bokmål', 'script' => 'Latn', 'native' => 'Bokmål', 'regional' => 'nb_NO'],
// 'bs' => ['name' => 'Bosnian', 'script' => 'Latn', 'native' => 'bosanski', 'regional' => 'bs_BA'],
// 'br' => ['name' => 'Breton', 'script' => 'Latn', 'native' => 'brezhoneg', 'regional' => 'br_FR'],
// 'ca' => ['name' => 'Catalan', 'script' => 'Latn', 'native' => 'català', 'regional' => 'ca_ES'],
// 'ch' => ['name' => 'Chamorro', 'script' => 'Latn', 'native' => 'Chamoru', 'regional' => ''],
// 'ny' => ['name' => 'Chewa', 'script' => 'Latn', 'native' => 'chiCheŵa', 'regional' => ''],
// 'kde' => ['name' => 'Makonde', 'script' => 'Latn', 'native' => 'Chimakonde', 'regional' => ''],
// 'sn' => ['name' => 'Shona', 'script' => 'Latn', 'native' => 'chiShona', 'regional' => ''],
// 'co' => ['name' => 'Corsican', 'script' => 'Latn', 'native' => 'corsu', 'regional' => ''],
// 'cy' => ['name' => 'Welsh', 'script' => 'Latn', 'native' => 'Cymraeg', 'regional' => 'cy_GB'],
// 'da' => ['name' => 'Danish', 'script' => 'Latn', 'native' => 'dansk', 'regional' => 'da_DK'],
// 'se' => ['name' => 'Northern Sami', 'script' => 'Latn', 'native' => 'davvisámegiella', 'regional' => 'se_NO'],
// 'de' => ['name' => 'German', 'script' => 'Latn', 'native' => 'Deutsch', 'regional' => 'de_DE'],
// 'luo' => ['name' => 'Luo', 'script' => 'Latn', 'native' => 'Dholuo', 'regional' => ''],
// 'nv' => ['name' => 'Navajo', 'script' => 'Latn', 'native' => 'Diné bizaad', 'regional' => ''],
// 'dua' => ['name' => 'Duala', 'script' => 'Latn', 'native' => 'duálá', 'regional' => ''],
// 'et' => ['name' => 'Estonian', 'script' => 'Latn', 'native' => 'eesti', 'regional' => 'et_EE'],
// 'na' => ['name' => 'Nauru', 'script' => 'Latn', 'native' => 'Ekakairũ Naoero', 'regional' => ''],
// 'guz' => ['name' => 'Ekegusii', 'script' => 'Latn', 'native' => 'Ekegusii', 'regional' => ''],
'en' => ['name' => 'English', 'script' => 'Latn', 'native' => 'English', 'regional' => 'en_GB'],
// 'en-AU' => ['name' => 'Australian English', 'script' => 'Latn', 'native' => 'Australian English', 'regional' => 'en_AU'],
// 'en-GB' => ['name' => 'British English', 'script' => 'Latn', 'native' => 'British English', 'regional' => 'en_GB'],
// 'en-CA' => ['name' => 'Canadian English', 'script' => 'Latn', 'native' => 'Canadian English', 'regional' => 'en_CA'],
// 'en-US' => ['name' => 'U.S. English', 'script' => 'Latn', 'native' => 'U.S. English', 'regional' => 'en_US'],
// 'es' => ['name' => 'Spanish', 'script' => 'Latn', 'native' => 'español', 'regional' => 'es_ES'],
// 'eo' => ['name' => 'Esperanto', 'script' => 'Latn', 'native' => 'esperanto', 'regional' => ''],
// 'eu' => ['name' => 'Basque', 'script' => 'Latn', 'native' => 'euskara', 'regional' => 'eu_ES'],
// 'ewo' => ['name' => 'Ewondo', 'script' => 'Latn', 'native' => 'ewondo', 'regional' => ''],
// 'ee' => ['name' => 'Ewe', 'script' => 'Latn', 'native' => 'eʋegbe', 'regional' => ''],
// 'fil' => ['name' => 'Filipino', 'script' => 'Latn', 'native' => 'Filipino', 'regional' => 'fil_PH'],
'fr' => ['name' => 'French', 'script' => 'Latn', 'native' => 'français', 'regional' => 'fr_FR'],
// 'fr-CA' => ['name' => 'Canadian French', 'script' => 'Latn', 'native' => 'français canadien', 'regional' => 'fr_CA'],
// 'fy' => ['name' => 'Western Frisian', 'script' => 'Latn', 'native' => 'frysk', 'regional' => 'fy_DE'],
// 'fur' => ['name' => 'Friulian', 'script' => 'Latn', 'native' => 'furlan', 'regional' => 'fur_IT'],
// 'fo' => ['name' => 'Faroese', 'script' => 'Latn', 'native' => 'føroyskt', 'regional' => 'fo_FO'],
// 'gaa' => ['name' => 'Ga', 'script' => 'Latn', 'native' => 'Ga', 'regional' => ''],
// 'ga' => ['name' => 'Irish', 'script' => 'Latn', 'native' => 'Gaeilge', 'regional' => 'ga_IE'],
// 'gv' => ['name' => 'Manx', 'script' => 'Latn', 'native' => 'Gaelg', 'regional' => 'gv_GB'],
// 'sm' => ['name' => 'Samoan', 'script' => 'Latn', 'native' => 'Gagana fa’a Sāmoa', 'regional' => ''],
// 'gl' => ['name' => 'Galician', 'script' => 'Latn', 'native' => 'galego', 'regional' => 'gl_ES'],
// 'ki' => ['name' => 'Kikuyu', 'script' => 'Latn', 'native' => 'Gikuyu', 'regional' => ''],
// 'gd' => ['name' => 'Scottish Gaelic', 'script' => 'Latn', 'native' => 'Gàidhlig', 'regional' => 'gd_GB'],
// 'ha' => ['name' => 'Hausa', 'script' => 'Latn', 'native' => 'Hausa', 'regional' => 'ha_NG'],
// 'bez' => ['name' => 'Bena', 'script' => 'Latn', 'native' => 'Hibena', 'regional' => ''],
// 'ho' => ['name' => 'Hiri Motu', 'script' => 'Latn', 'native' => 'Hiri Motu', 'regional' => ''],
// 'hr' => ['name' => 'Croatian', 'script' => 'Latn', 'native' => 'hrvatski', 'regional' => 'hr_HR'],
// 'bem' => ['name' => 'Bemba', 'script' => 'Latn', 'native' => 'Ichibemba', 'regional' => 'bem_ZM'],
// 'io' => ['name' => 'Ido', 'script' => 'Latn', 'native' => 'Ido', 'regional' => ''],
// 'ig' => ['name' => 'Igbo', 'script' => 'Latn', 'native' => 'Igbo', 'regional' => 'ig_NG'],
// 'rn' => ['name' => 'Rundi', 'script' => 'Latn', 'native' => 'Ikirundi', 'regional' => ''],
// 'ia' => ['name' => 'Interlingua', 'script' => 'Latn', 'native' => 'interlingua', 'regional' => 'ia_FR'],
// 'iu-Latn' => ['name' => 'Inuktitut (Latin)', 'script' => 'Latn', 'native' => 'Inuktitut', 'regional' => 'iu_CA'],
// 'sbp' => ['name' => 'Sileibi', 'script' => 'Latn', 'native' => 'Ishisangu', 'regional' => ''],
// 'nd' => ['name' => 'North Ndebele', 'script' => 'Latn', 'native' => 'isiNdebele', 'regional' => ''],
// 'nr' => ['name' => 'South Ndebele', 'script' => 'Latn', 'native' => 'isiNdebele', 'regional' => 'nr_ZA'],
// 'xh' => ['name' => 'Xhosa', 'script' => 'Latn', 'native' => 'isiXhosa', 'regional' => 'xh_ZA'],
// 'zu' => ['name' => 'Zulu', 'script' => 'Latn', 'native' => 'isiZulu', 'regional' => 'zu_ZA'],
// 'it' => ['name' => 'Italian', 'script' => 'Latn', 'native' => 'italiano', 'regional' => 'it_IT'],
// 'ik' => ['name' => 'Inupiaq', 'script' => 'Latn', 'native' => 'Iñupiaq', 'regional' => 'ik_CA'],
// 'dyo' => ['name' => 'Jola-Fonyi', 'script' => 'Latn', 'native' => 'joola', 'regional' => ''],
// 'kea' => ['name' => 'Kabuverdianu', 'script' => 'Latn', 'native' => 'kabuverdianu', 'regional' => ''],
// 'kaj' => ['name' => 'Jju', 'script' => 'Latn', 'native' => 'Kaje', 'regional' => ''],
// 'mh' => ['name' => 'Marshallese', 'script' => 'Latn', 'native' => 'Kajin M̧ajeļ', 'regional' => 'mh_MH'],
// 'kl' => ['name' => 'Kalaallisut', 'script' => 'Latn', 'native' => 'kalaallisut', 'regional' => 'kl_GL'],
// 'kln' => ['name' => 'Kalenjin', 'script' => 'Latn', 'native' => 'Kalenjin', 'regional' => ''],
// 'kr' => ['name' => 'Kanuri', 'script' => 'Latn', 'native' => 'Kanuri', 'regional' => ''],
// 'kcg' => ['name' => 'Tyap', 'script' => 'Latn', 'native' => 'Katab', 'regional' => ''],
// 'kw' => ['name' => 'Cornish', 'script' => 'Latn', 'native' => 'kernewek', 'regional' => 'kw_GB'],
// 'naq' => ['name' => 'Nama', 'script' => 'Latn', 'native' => 'Khoekhoegowab', 'regional' => ''],
// 'rof' => ['name' => 'Rombo', 'script' => 'Latn', 'native' => 'Kihorombo', 'regional' => ''],
// 'kam' => ['name' => 'Kamba', 'script' => 'Latn', 'native' => 'Kikamba', 'regional' => ''],
// 'kg' => ['name' => 'Kongo', 'script' => 'Latn', 'native' => 'Kikongo', 'regional' => ''],
// 'jmc' => ['name' => 'Machame', 'script' => 'Latn', 'native' => 'Kimachame', 'regional' => ''],
// 'rw' => ['name' => 'Kinyarwanda', 'script' => 'Latn', 'native' => 'Kinyarwanda', 'regional' => 'rw_RW'],
// 'asa' => ['name' => 'Kipare', 'script' => 'Latn', 'native' => 'Kipare', 'regional' => ''],
// 'rwk' => ['name' => 'Rwa', 'script' => 'Latn', 'native' => 'Kiruwa', 'regional' => ''],
// 'saq' => ['name' => 'Samburu', 'script' => 'Latn', 'native' => 'Kisampur', 'regional' => ''],
// 'ksb' => ['name' => 'Shambala', 'script' => 'Latn', 'native' => 'Kishambaa', 'regional' => ''],
// 'swc' => ['name' => 'Congo Swahili', 'script' => 'Latn', 'native' => 'Kiswahili ya Kongo', 'regional' => ''],
// 'sw' => ['name' => 'Swahili', 'script' => 'Latn', 'native' => 'Kiswahili', 'regional' => 'sw_KE'],
// 'dav' => ['name' => 'Dawida', 'script' => 'Latn', 'native' => 'Kitaita', 'regional' => ''],
// 'teo' => ['name' => 'Teso', 'script' => 'Latn', 'native' => 'Kiteso', 'regional' => ''],
// 'khq' => ['name' => 'Koyra Chiini', 'script' => 'Latn', 'native' => 'Koyra ciini', 'regional' => ''],
// 'ses' => ['name' => 'Songhay', 'script' => 'Latn', 'native' => 'Koyraboro senni', 'regional' => ''],
// 'mfe' => ['name' => 'Morisyen', 'script' => 'Latn', 'native' => 'kreol morisien', 'regional' => ''],
// 'ht' => ['name' => 'Haitian', 'script' => 'Latn', 'native' => 'Kreyòl ayisyen', 'regional' => 'ht_HT'],
// 'kj' => ['name' => 'Kuanyama', 'script' => 'Latn', 'native' => 'Kwanyama', 'regional' => ''],
// 'ksh' => ['name' => 'Kölsch', 'script' => 'Latn', 'native' => 'Kölsch', 'regional' => ''],
// 'ebu' => ['name' => 'Kiembu', 'script' => 'Latn', 'native' => 'Kĩembu', 'regional' => ''],
// 'mer' => ['name' => 'Kimîîru', 'script' => 'Latn', 'native' => 'Kĩmĩrũ', 'regional' => ''],
// 'lag' => ['name' => 'Langi', 'script' => 'Latn', 'native' => 'Kɨlaangi', 'regional' => ''],
// 'lah' => ['name' => 'Lahnda', 'script' => 'Latn', 'native' => 'Lahnda', 'regional' => ''],
// 'la' => ['name' => 'Latin', 'script' => 'Latn', 'native' => 'latine', 'regional' => ''],
// 'lv' => ['name' => 'Latvian', 'script' => 'Latn', 'native' => 'latviešu', 'regional' => 'lv_LV'],
// 'to' => ['name' => 'Tongan', 'script' => 'Latn', 'native' => 'lea fakatonga', 'regional' => ''],
// 'lt' => ['name' => 'Lithuanian', 'script' => 'Latn', 'native' => 'lietuvių', 'regional' => 'lt_LT'],
// 'li' => ['name' => 'Limburgish', 'script' => 'Latn', 'native' => 'Limburgs', 'regional' => 'li_BE'],
// 'ln' => ['name' => 'Lingala', 'script' => 'Latn', 'native' => 'lingála', 'regional' => ''],
// 'lg' => ['name' => 'Ganda', 'script' => 'Latn', 'native' => 'Luganda', 'regional' => 'lg_UG'],
// 'luy' => ['name' => 'Oluluyia', 'script' => 'Latn', 'native' => 'Luluhia', 'regional' => ''],
// 'lb' => ['name' => 'Luxembourgish', 'script' => 'Latn', 'native' => 'Lëtzebuergesch', 'regional' => 'lb_LU'],
// 'hu' => ['name' => 'Hungarian', 'script' => 'Latn', 'native' => 'magyar', 'regional' => 'hu_HU'],
// 'mgh' => ['name' => 'Makhuwa-Meetto', 'script' => 'Latn', 'native' => 'Makua', 'regional' => ''],
// 'mg' => ['name' => 'Malagasy', 'script' => 'Latn', 'native' => 'Malagasy', 'regional' => 'mg_MG'],
// 'mt' => ['name' => 'Maltese', 'script' => 'Latn', 'native' => 'Malti', 'regional' => 'mt_MT'],
// 'mtr' => ['name' => 'Mewari', 'script' => 'Latn', 'native' => 'Mewari', 'regional' => ''],
// 'mua' => ['name' => 'Mundang', 'script' => 'Latn', 'native' => 'Mundang', 'regional' => ''],
// 'mi' => ['name' => 'Māori', 'script' => 'Latn', 'native' => 'Māori', 'regional' => 'mi_NZ'],
// 'nl' => ['name' => 'Dutch', 'script' => 'Latn', 'native' => 'Nederlands', 'regional' => 'nl_NL'],
// 'nmg' => ['name' => 'Kwasio', 'script' => 'Latn', 'native' => 'ngumba', 'regional' => ''],
// 'yav' => ['name' => 'Yangben', 'script' => 'Latn', 'native' => 'nuasue', 'regional' => ''],
// 'nn' => ['name' => 'Norwegian Nynorsk', 'script' => 'Latn', 'native' => 'nynorsk', 'regional' => 'nn_NO'],
// 'oc' => ['name' => 'Occitan', 'script' => 'Latn', 'native' => 'occitan', 'regional' => 'oc_FR'],
// 'ang' => ['name' => 'Old English', 'script' => 'Runr', 'native' => 'Old English', 'regional' => ''],
// 'xog' => ['name' => 'Soga', 'script' => 'Latn', 'native' => 'Olusoga', 'regional' => ''],
// 'om' => ['name' => 'Oromo', 'script' => 'Latn', 'native' => 'Oromoo', 'regional' => 'om_ET'],
// 'ng' => ['name' => 'Ndonga', 'script' => 'Latn', 'native' => 'OshiNdonga', 'regional' => ''],
// 'hz' => ['name' => 'Herero', 'script' => 'Latn', 'native' => 'Otjiherero', 'regional' => ''],
// 'uz-Latn' => ['name' => 'Uzbek (Latin)', 'script' => 'Latn', 'native' => 'oʼzbekcha', 'regional' => 'uz_UZ'],
// 'nds' => ['name' => 'Low German', 'script' => 'Latn', 'native' => 'Plattdüütsch', 'regional' => 'nds_DE'],
// 'pl' => ['name' => 'Polish', 'script' => 'Latn', 'native' => 'polski', 'regional' => 'pl_PL'],
// 'pt' => ['name' => 'Portuguese', 'script' => 'Latn', 'native' => 'português', 'regional' => 'pt_PT'],
// 'pt-BR' => ['name' => 'Brazilian Portuguese', 'script' => 'Latn', 'native' => 'português do Brasil', 'regional' => 'pt_BR'],
// 'ff' => ['name' => 'Fulah', 'script' => 'Latn', 'native' => 'Pulaar', 'regional' => 'ff_SN'],
// 'pi' => ['name' => 'Pahari-Potwari', 'script' => 'Latn', 'native' => 'Pāli', 'regional' => ''],
// 'aa' => ['name' => 'Afar', 'script' => 'Latn', 'native' => 'Qafar', 'regional' => 'aa_ER'],
// 'ty' => ['name' => 'Tahitian', 'script' => 'Latn', 'native' => 'Reo Māohi', 'regional' => ''],
// 'ksf' => ['name' => 'Bafia', 'script' => 'Latn', 'native' => 'rikpa', 'regional' => ''],
// 'ro' => ['name' => 'Romanian', 'script' => 'Latn', 'native' => 'română', 'regional' => 'ro_RO'],
// 'cgg' => ['name' => 'Chiga', 'script' => 'Latn', 'native' => 'Rukiga', 'regional' => ''],
// 'rm' => ['name' => 'Romansh', 'script' => 'Latn', 'native' => 'rumantsch', 'regional' => ''],
// 'qu' => ['name' => 'Quechua', 'script' => 'Latn', 'native' => 'Runa Simi', 'regional' => ''],
// 'nyn' => ['name' => 'Nyankole', 'script' => 'Latn', 'native' => 'Runyankore', 'regional' => ''],
// 'ssy' => ['name' => 'Saho', 'script' => 'Latn', 'native' => 'Saho', 'regional' => ''],
// 'sc' => ['name' => 'Sardinian', 'script' => 'Latn', 'native' => 'sardu', 'regional' => 'sc_IT'],
// 'de-CH' => ['name' => 'Swiss High German', 'script' => 'Latn', 'native' => 'Schweizer Hochdeutsch', 'regional' => 'de_CH'],
// 'gsw' => ['name' => 'Swiss German', 'script' => 'Latn', 'native' => 'Schwiizertüütsch', 'regional' => ''],
// 'trv' => ['name' => 'Taroko', 'script' => 'Latn', 'native' => 'Seediq', 'regional' => ''],
// 'seh' => ['name' => 'Sena', 'script' => 'Latn', 'native' => 'sena', 'regional' => ''],
// 'nso' => ['name' => 'Northern Sotho', 'script' => 'Latn', 'native' => 'Sesotho sa Leboa', 'regional' => 'nso_ZA'],
// 'st' => ['name' => 'Southern Sotho', 'script' => 'Latn', 'native' => 'Sesotho', 'regional' => 'st_ZA'],
// 'tn' => ['name' => 'Tswana', 'script' => 'Latn', 'native' => 'Setswana', 'regional' => 'tn_ZA'],
// 'sq' => ['name' => 'Albanian', 'script' => 'Latn', 'native' => 'shqip', 'regional' => 'sq_AL'],
// 'sid' => ['name' => 'Sidamo', 'script' => 'Latn', 'native' => 'Sidaamu Afo', 'regional' => 'sid_ET'],
// 'ss' => ['name' => 'Swati', 'script' => 'Latn', 'native' => 'Siswati', 'regional' => 'ss_ZA'],
// 'sk' => ['name' => 'Slovak', 'script' => 'Latn', 'native' => 'slovenčina', 'regional' => 'sk_SK'],
// 'sl' => ['name' => 'Slovene', 'script' => 'Latn', 'native' => 'slovenščina', 'regional' => 'sl_SI'],
// 'so' => ['name' => 'Somali', 'script' => 'Latn', 'native' => 'Soomaali', 'regional' => 'so_SO'],
// 'sr-Latn' => ['name' => 'Serbian (Latin)', 'script' => 'Latn', 'native' => 'Srpski', 'regional' => 'sr_RS'],
// 'sh' => ['name' => 'Serbo-Croatian', 'script' => 'Latn', 'native' => 'srpskohrvatski', 'regional' => ''],
// 'fi' => ['name' => 'Finnish', 'script' => 'Latn', 'native' => 'suomi', 'regional' => 'fi_FI'],
// 'sv' => ['name' => 'Swedish', 'script' => 'Latn', 'native' => 'svenska', 'regional' => 'sv_SE'],
// 'sg' => ['name' => 'Sango', 'script' => 'Latn', 'native' => 'Sängö', 'regional' => ''],
// 'tl' => ['name' => 'Tagalog', 'script' => 'Latn', 'native' => 'Tagalog', 'regional' => 'tl_PH'],
// 'tzm-Latn' => ['name' => 'Central Atlas Tamazight (Latin)', 'script' => 'Latn', 'native' => 'Tamazight', 'regional' => ''],
// 'kab' => ['name' => 'Kabyle', 'script' => 'Latn', 'native' => 'Taqbaylit', 'regional' => 'kab_DZ'],
// 'twq' => ['name' => 'Tasawaq', 'script' => 'Latn', 'native' => 'Tasawaq senni', 'regional' => ''],
// 'shi' => ['name' => 'Tachelhit (Latin)', 'script' => 'Latn', 'native' => 'Tashelhit', 'regional' => ''],
// 'nus' => ['name' => 'Nuer', 'script' => 'Latn', 'native' => 'Thok Nath', 'regional' => ''],
// 'vi' => ['name' => 'Vietnamese', 'script' => 'Latn', 'native' => 'Tiếng Việt', 'regional' => 'vi_VN'],
// 'tg-Latn' => ['name' => 'Tajik (Latin)', 'script' => 'Latn', 'native' => 'tojikī', 'regional' => 'tg_TJ'],
// 'lu' => ['name' => 'Luba-Katanga', 'script' => 'Latn', 'native' => 'Tshiluba', 'regional' => 've_ZA'],
// 've' => ['name' => 'Venda', 'script' => 'Latn', 'native' => 'Tshivenḓa', 'regional' => ''],
// 'tw' => ['name' => 'Twi', 'script' => 'Latn', 'native' => 'Twi', 'regional' => ''],
// 'tr' => ['name' => 'Turkish', 'script' => 'Latn', 'native' => 'Türkçe', 'regional' => 'tr_TR'],
// 'ale' => ['name' => 'Aleut', 'script' => 'Latn', 'native' => 'Unangax tunuu', 'regional' => ''],
// 'ca-valencia' => ['name' => 'Valencian', 'script' => 'Latn', 'native' => 'valencià', 'regional' => ''],
// 'vai-Latn' => ['name' => 'Vai (Latin)', 'script' => 'Latn', 'native' => 'Viyamíĩ', 'regional' => ''],
// 'vo' => ['name' => 'Volapük', 'script' => 'Latn', 'native' => 'Volapük', 'regional' => ''],
// 'fj' => ['name' => 'Fijian', 'script' => 'Latn', 'native' => 'vosa Vakaviti', 'regional' => ''],
// 'wa' => ['name' => 'Walloon', 'script' => 'Latn', 'native' => 'Walon', 'regional' => 'wa_BE'],
// 'wae' => ['name' => 'Walser', 'script' => 'Latn', 'native' => 'Walser', 'regional' => 'wae_CH'],
// 'wen' => ['name' => 'Sorbian', 'script' => 'Latn', 'native' => 'Wendic', 'regional' => ''],
// 'wo' => ['name' => 'Wolof', 'script' => 'Latn', 'native' => 'Wolof', 'regional' => 'wo_SN'],
// 'ts' => ['name' => 'Tsonga', 'script' => 'Latn', 'native' => 'Xitsonga', 'regional' => 'ts_ZA'],
// 'dje' => ['name' => 'Zarma', 'script' => 'Latn', 'native' => 'Zarmaciine', 'regional' => ''],
// 'yo' => ['name' => 'Yoruba', 'script' => 'Latn', 'native' => 'Èdè Yorùbá', 'regional' => 'yo_NG'],
// 'de-AT' => ['name' => 'Austrian German', 'script' => 'Latn', 'native' => 'Österreichisches Deutsch', 'regional' => 'de_AT'],
// 'is' => ['name' => 'Icelandic', 'script' => 'Latn', 'native' => 'íslenska', 'regional' => 'is_IS'],
// 'cs' => ['name' => 'Czech', 'script' => 'Latn', 'native' => 'čeština', 'regional' => 'cs_CZ'],
// 'bas' => ['name' => 'Basa', 'script' => 'Latn', 'native' => 'Ɓàsàa', 'regional' => ''],
// 'mas' => ['name' => 'Masai', 'script' => 'Latn', 'native' => 'ɔl-Maa', 'regional' => ''],
// 'haw' => ['name' => 'Hawaiian', 'script' => 'Latn', 'native' => 'ʻŌlelo Hawaiʻi', 'regional' => ''],
// 'el' => ['name' => 'Greek', 'script' => 'Grek', 'native' => 'Ελληνικά', 'regional' => 'el_GR'],
// 'uz' => ['name' => 'Uzbek (Cyrillic)', 'script' => 'Cyrl', 'native' => 'Ўзбек', 'regional' => 'uz_UZ'],
// 'az-Cyrl' => ['name' => 'Azerbaijani (Cyrillic)', 'script' => 'Cyrl', 'native' => 'Азәрбајҹан', 'regional' => 'uz_UZ'],
// 'ab' => ['name' => 'Abkhazian', 'script' => 'Cyrl', 'native' => 'Аҧсуа', 'regional' => ''],
// 'os' => ['name' => 'Ossetic', 'script' => 'Cyrl', 'native' => 'Ирон', 'regional' => 'os_RU'],
// 'ky' => ['name' => 'Kyrgyz', 'script' => 'Cyrl', 'native' => 'Кыргыз', 'regional' => 'ky_KG'],
// 'sr' => ['name' => 'Serbian (Cyrillic)', 'script' => 'Cyrl', 'native' => 'Српски', 'regional' => 'sr_RS'],
// 'av' => ['name' => 'Avaric', 'script' => 'Cyrl', 'native' => 'авар мацӀ', 'regional' => ''],
// 'ady' => ['name' => 'Adyghe', 'script' => 'Cyrl', 'native' => 'адыгэбзэ', 'regional' => ''],
// 'ba' => ['name' => 'Bashkir', 'script' => 'Cyrl', 'native' => 'башҡорт теле', 'regional' => ''],
// 'be' => ['name' => 'Belarusian', 'script' => 'Cyrl', 'native' => 'беларуская', 'regional' => 'be_BY'],
// 'bg' => ['name' => 'Bulgarian', 'script' => 'Cyrl', 'native' => 'български', 'regional' => 'bg_BG'],
// 'kv' => ['name' => 'Komi', 'script' => 'Cyrl', 'native' => 'коми кыв', 'regional' => ''],
// 'mk' => ['name' => 'Macedonian', 'script' => 'Cyrl', 'native' => 'македонски', 'regional' => 'mk_MK'],
// 'mn' => ['name' => 'Mongolian (Cyrillic)', 'script' => 'Cyrl', 'native' => 'монгол', 'regional' => 'mn_MN'],
// 'ce' => ['name' => 'Chechen', 'script' => 'Cyrl', 'native' => 'нохчийн мотт', 'regional' => 'ce_RU'],
// 'ru' => ['name' => 'Russian', 'script' => 'Cyrl', 'native' => 'русский', 'regional' => 'ru_RU'],
// 'sah' => ['name' => 'Yakut', 'script' => 'Cyrl', 'native' => 'саха тыла', 'regional' => ''],
// 'tt' => ['name' => 'Tatar', 'script' => 'Cyrl', 'native' => 'татар теле', 'regional' => 'tt_RU'],
// 'tg' => ['name' => 'Tajik (Cyrillic)', 'script' => 'Cyrl', 'native' => 'тоҷикӣ', 'regional' => 'tg_TJ'],
// 'tk' => ['name' => 'Turkmen', 'script' => 'Cyrl', 'native' => 'түркменче', 'regional' => 'tk_TM'],
// 'uk' => ['name' => 'Ukrainian', 'script' => 'Cyrl', 'native' => 'українська', 'regional' => 'uk_UA'],
// 'cv' => ['name' => 'Chuvash', 'script' => 'Cyrl', 'native' => 'чӑваш чӗлхи', 'regional' => 'cv_RU'],
// 'cu' => ['name' => 'Church Slavic', 'script' => 'Cyrl', 'native' => 'ѩзыкъ словѣньскъ', 'regional' => ''],
// 'kk' => ['name' => 'Kazakh', 'script' => 'Cyrl', 'native' => 'қазақ тілі', 'regional' => 'kk_KZ'],
// 'hy' => ['name' => 'Armenian', 'script' => 'Armn', 'native' => 'Հայերեն', 'regional' => 'hy_AM'],
// 'yi' => ['name' => 'Yiddish', 'script' => 'Hebr', 'native' => 'ייִדיש', 'regional' => 'yi_US'],
// 'he' => ['name' => 'Hebrew', 'script' => 'Hebr', 'native' => 'עברית', 'regional' => 'he_IL'],
// 'ug' => ['name' => 'Uyghur', 'script' => 'Arab', 'native' => 'ئۇيغۇرچە', 'regional' => 'ug_CN'],
// 'ur' => ['name' => 'Urdu', 'script' => 'Arab', 'native' => 'اردو', 'regional' => 'ur_PK'],
// 'ar' => ['name' => 'Arabic', 'script' => 'Arab', 'native' => 'العربية', 'regional' => 'ar_AE'],
// 'uz-Arab' => ['name' => 'Uzbek (Arabic)', 'script' => 'Arab', 'native' => 'اۉزبېک', 'regional' => ''],
// 'tg-Arab' => ['name' => 'Tajik (Arabic)', 'script' => 'Arab', 'native' => 'تاجیکی', 'regional' => 'tg_TJ'],
// 'sd' => ['name' => 'Sindhi', 'script' => 'Arab', 'native' => 'سنڌي', 'regional' => 'sd_IN'],
// 'fa' => ['name' => 'Persian', 'script' => 'Arab', 'native' => 'فارسی', 'regional' => 'fa_IR'],
// 'pa-Arab' => ['name' => 'Punjabi (Arabic)', 'script' => 'Arab', 'native' => 'پنجاب', 'regional' => 'pa_IN'],
// 'ps' => ['name' => 'Pashto', 'script' => 'Arab', 'native' => 'پښتو', 'regional' => 'ps_AF'],
// 'ks' => ['name' => 'Kashmiri (Arabic)', 'script' => 'Arab', 'native' => 'کأشُر', 'regional' => 'ks_IN'],
// 'ku' => ['name' => 'Kurdish', 'script' => 'Arab', 'native' => 'کوردی', 'regional' => 'ku_TR'],
// 'dv' => ['name' => 'Divehi', 'script' => 'Thaa', 'native' => 'ދިވެހިބަސް', 'regional' => 'dv_MV'],
// 'ks-Deva' => ['name' => 'Kashmiri (Devaganari)', 'script' => 'Deva', 'native' => 'कॉशुर', 'regional' => 'ks_IN'],
// 'kok' => ['name' => 'Konkani', 'script' => 'Deva', 'native' => 'कोंकणी', 'regional' => 'kok_IN'],
// 'doi' => ['name' => 'Dogri', 'script' => 'Deva', 'native' => 'डोगरी', 'regional' => 'doi_IN'],
// 'ne' => ['name' => 'Nepali', 'script' => 'Deva', 'native' => 'नेपाली', 'regional' => ''],
// 'pra' => ['name' => 'Prakrit', 'script' => 'Deva', 'native' => 'प्राकृत', 'regional' => ''],
// 'brx' => ['name' => 'Bodo', 'script' => 'Deva', 'native' => 'बड़ो', 'regional' => 'brx_IN'],
// 'bra' => ['name' => 'Braj', 'script' => 'Deva', 'native' => 'ब्रज भाषा', 'regional' => ''],
// 'mr' => ['name' => 'Marathi', 'script' => 'Deva', 'native' => 'मराठी', 'regional' => 'mr_IN'],
// 'mai' => ['name' => 'Maithili', 'script' => 'Tirh', 'native' => 'मैथिली', 'regional' => 'mai_IN'],
// 'raj' => ['name' => 'Rajasthani', 'script' => 'Deva', 'native' => 'राजस्थानी', 'regional' => ''],
// 'sa' => ['name' => 'Sanskrit', 'script' => 'Deva', 'native' => 'संस्कृतम्', 'regional' => 'sa_IN'],
// 'hi' => ['name' => 'Hindi', 'script' => 'Deva', 'native' => 'हिन्दी', 'regional' => 'hi_IN'],
// 'as' => ['name' => 'Assamese', 'script' => 'Beng', 'native' => 'অসমীয়া', 'regional' => 'as_IN'],
// 'bn' => ['name' => 'Bengali', 'script' => 'Beng', 'native' => 'বাংলা', 'regional' => 'bn_BD'],
// 'mni' => ['name' => 'Manipuri', 'script' => 'Beng', 'native' => 'মৈতৈ', 'regional' => 'mni_IN'],
// 'pa' => ['name' => 'Punjabi (Gurmukhi)', 'script' => 'Guru', 'native' => 'ਪੰਜਾਬੀ', 'regional' => 'pa_IN'],
// 'gu' => ['name' => 'Gujarati', 'script' => 'Gujr', 'native' => 'ગુજરાતી', 'regional' => 'gu_IN'],
// 'or' => ['name' => 'Oriya', 'script' => 'Orya', 'native' => 'ଓଡ଼ିଆ', 'regional' => 'or_IN'],
// 'ta' => ['name' => 'Tamil', 'script' => 'Taml', 'native' => 'தமிழ்', 'regional' => 'ta_IN'],
// 'te' => ['name' => 'Telugu', 'script' => 'Telu', 'native' => 'తెలుగు', 'regional' => 'te_IN'],
// 'kn' => ['name' => 'Kannada', 'script' => 'Knda', 'native' => 'ಕನ್ನಡ', 'regional' => 'kn_IN'],
// 'ml' => ['name' => 'Malayalam', 'script' => 'Mlym', 'native' => 'മലയാളം', 'regional' => 'ml_IN'],
// 'si' => ['name' => 'Sinhala', 'script' => 'Sinh', 'native' => 'සිංහල', 'regional' => 'si_LK'],
// 'th' => ['name' => 'Thai', 'script' => 'Thai', 'native' => 'ไทย', 'regional' => 'th_TH'],
// 'lo' => ['name' => 'Lao', 'script' => 'Laoo', 'native' => 'ລາວ', 'regional' => 'lo_LA'],
// 'bo' => ['name' => 'Tibetan', 'script' => 'Tibt', 'native' => 'པོད་སྐད་', 'regional' => 'bo_IN'],
// 'dz' => ['name' => 'Dzongkha', 'script' => 'Tibt', 'native' => 'རྫོང་ཁ', 'regional' => 'dz_BT'],
// 'my' => ['name' => 'Burmese', 'script' => 'Mymr', 'native' => 'မြန်မာဘာသာ', 'regional' => 'my_MM'],
// 'ka' => ['name' => 'Georgian', 'script' => 'Geor', 'native' => 'ქართული', 'regional' => 'ka_GE'],
// 'byn' => ['name' => 'Blin', 'script' => 'Ethi', 'native' => 'ብሊን', 'regional' => 'byn_ER'],
// 'tig' => ['name' => 'Tigre', 'script' => 'Ethi', 'native' => 'ትግረ', 'regional' => 'tig_ER'],
// 'ti' => ['name' => 'Tigrinya', 'script' => 'Ethi', 'native' => 'ትግርኛ', 'regional' => 'ti_ET'],
// 'am' => ['name' => 'Amharic', 'script' => 'Ethi', 'native' => 'አማርኛ', 'regional' => 'am_ET'],
// 'wal' => ['name' => 'Wolaytta', 'script' => 'Ethi', 'native' => 'ወላይታቱ', 'regional' => 'wal_ET'],
// 'chr' => ['name' => 'Cherokee', 'script' => 'Cher', 'native' => 'ᏣᎳᎩ', 'regional' => ''],
// 'iu' => ['name' => 'Inuktitut (Canadian Aboriginal Syllabics)', 'script' => 'Cans', 'native' => 'ᐃᓄᒃᑎᑐᑦ', 'regional' => 'iu_CA'],
// 'oj' => ['name' => 'Ojibwa', 'script' => 'Cans', 'native' => 'ᐊᓂᔑᓈᐯᒧᐎᓐ', 'regional' => ''],
// 'cr' => ['name' => 'Cree', 'script' => 'Cans', 'native' => 'ᓀᐦᐃᔭᐍᐏᐣ', 'regional' => ''],
// 'km' => ['name' => 'Khmer', 'script' => 'Khmr', 'native' => 'ភាសាខ្មែរ', 'regional' => 'km_KH'],
// 'mn-Mong' => ['name' => 'Mongolian (Mongolian)', 'script' => 'Mong', 'native' => 'ᠮᠣᠨᠭᠭᠣᠯ ᠬᠡᠯᠡ', 'regional' => 'mn_MN'],
// 'shi-Tfng' => ['name' => 'Tachelhit (Tifinagh)', 'script' => 'Tfng', 'native' => 'ⵜⴰⵎⴰⵣⵉⵖⵜ', 'regional' => ''],
// 'tzm' => ['name' => 'Central Atlas Tamazight (Tifinagh)','script' => 'Tfng', 'native' => 'ⵜⴰⵎⴰⵣⵉⵖⵜ', 'regional' => ''],
// 'yue' => ['name' => 'Yue', 'script' => 'Hant', 'native' => '廣州話', 'regional' => 'yue_HK'],
// 'ja' => ['name' => 'Japanese', 'script' => 'Jpan', 'native' => '日本語', 'regional' => 'ja_JP'],
// 'zh' => ['name' => 'Chinese (Simplified)', 'script' => 'Hans', 'native' => '简体中文', 'regional' => 'zh_CN'],
// 'zh-Hant' => ['name' => 'Chinese (Traditional)', 'script' => 'Hant', 'native' => '繁體中文', 'regional' => 'zh_CN'],
// 'ii' => ['name' => 'Sichuan Yi', 'script' => 'Yiii', 'native' => 'ꆈꌠꉙ', 'regional' => ''],
// 'vai' => ['name' => 'Vai (Vai)', 'script' => 'Vaii', 'native' => 'ꕙꔤ', 'regional' => ''],
// 'jv-Java' => ['name' => 'Javanese (Javanese)', 'script' => 'Java', 'native' => 'ꦧꦱꦗꦮ', 'regional' => ''],
// 'ko' => ['name' => 'Korean', 'script' => 'Hang', 'native' => '한국어', 'regional' => 'ko_KR'],
],
// @phpcs:enable
// Requires middleware `LaravelSessionRedirect.php`.
//
// Automatically determine locale from browser
// (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language)
// on first call if it's not defined in the URL. Redirect user to computed localized url.
// For example, if users browser language is `de`, and `de` is active in the array `supportedLocales`,
// the `/about` would be redirected to `/de/about`.
//
// The locale will be stored in session and only be computed from browser
// again if the session expires.
//
// If false, system will take app.php locale attribute.
'useAcceptLanguageHeader' => false,
// If `hideDefaultLocaleInURL` is true, then a url without locale
// is identical with the same url with default locale.
// For example, if `en` is default locale, then `/en/about` and `/about`
// would be identical.
//
// If in addition the middleware `LaravelLocalizationRedirectFilter` is active, then
// every url with default locale is redirected to url without locale.
// For example, `/en/about` would be redirected to `/about`.
// It is recommended to use `hideDefaultLocaleInURL` only in
// combination with the middleware `LaravelLocalizationRedirectFilter`
// to avoid duplicate content (SEO).
//
// If `useAcceptLanguageHeader` is true, then the first time
// the locale will be determined from browser and redirect to that language.
// After that, `hideDefaultLocaleInURL` behaves as usual.
'hideDefaultLocaleInURL' => true,
// If you want to display the locales in particular order in the language selector you should write the order here.
// CAUTION: Please consider using the appropriate locale code otherwise it will not work
// Example: 'localesOrder' => ['es','en'].
'localesOrder' => ['fr', 'en'],
// If you want to use custom language URL segments like 'at' instead of 'de-AT', you can map them to allow the
// LanguageNegotiator to assign the desired locales based on HTTP Accept Language Header. For example, if you want
// to use 'at' instead of 'de-AT', you would map 'de-AT' to 'at' (ie. ['de-AT' => 'at']).
'localesMapping' => [],
// Locale suffix for LC_TIME and LC_MONETARY
// Defaults to most common ".UTF-8". Set to blank on Windows systems, change to ".utf8" on CentOS and similar.
'utf8suffix' => env('LARAVELLOCALIZATION_UTF8SUFFIX', '.UTF-8'),
// URLs which should not be processed, e.g. '/nova', '/nova/*', '/nova-api/*' or specific application URLs
// Defaults to [].
'urlsIgnored' => ['/skipped'],
'httpMethodsIgnored' => ['POST', 'PUT', 'PATCH', 'DELETE'],
];

150
config/logging.php Normal file
View file

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
*--------------------------------------------------------------------------
* Default Log Channel
*--------------------------------------------------------------------------
*
* This option defines the default log channel that is utilized to write
* messages to your logs. The value provided here should match one of
* the channels present in the list of "channels" configured below.
*
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
*--------------------------------------------------------------------------
* Deprecations Log Channel
*--------------------------------------------------------------------------
*
* This option controls the log channel that should be used to log warnings
* regarding deprecated PHP and library features. This allows you to get
* your application ready for upcoming major versions of dependencies.
*
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
*--------------------------------------------------------------------------
* Log Channels
*--------------------------------------------------------------------------
*
* Here you may configure the log channels for your application. Laravel
* utilizes the Monolog PHP logging library, which includes a variety
* of powerful log handlers and formatters that you're free to use.
*
* Available drivers: "single", "daily", "slack", "syslog",
* "errorlog", "monolog", "custom", "stack"
*
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://' . env('PAPERTRAIL_URL') . ':' . env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
'stdout' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'formatter' => env('LOG_STDOUT_FORMATTER'),
'with' => [
'stream' => 'php://stdout',
],
],
'k8s' => [
'driver' => 'stack',
'channels' => ['daily', 'stdout'],
'ignore_exceptions' => false,
],
],
];

119
config/mail.php Normal file
View file

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
return [
/*
*--------------------------------------------------------------------------
* Default Mailer
*--------------------------------------------------------------------------
*
* This option controls the default mailer that is used to send all email
* messages unless another mailer is explicitly specified when sending
* the message. All additional mailers can be configured within the
* "mailers" array. Examples of each type of mailer are provided.
*
*/
'default' => env('MAIL_MAILER', 'smtp'),
/*
*--------------------------------------------------------------------------
* Mailer Configurations
*--------------------------------------------------------------------------
*
* Here you may configure all of the mailers used by your application plus
* their respective settings. Several examples have been configured for
* you and you are free to add your own as your application requires.
*
* Laravel supports a variety of mail "transport" drivers that can be used
* when delivering an email. You may specify which one you're using for
* your mailers below. You may also add additional mailers if needed.
*
* Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
* "postmark", "resend", "log", "array",
* "failover", "roundrobin"
*
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env(
'MAIL_EHLO_DOMAIN',
parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)
),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
*--------------------------------------------------------------------------
* Global "From" Address
*--------------------------------------------------------------------------
*
* You may wish for all emails sent by your application to be sent from
* the same address. Here you may specify a name and address that is
* used globally for all emails that are sent by your application.
*
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', 'Example'),
],
];

133
config/purifier.php Normal file
View file

@ -0,0 +1,133 @@
<?php
/**
* Ok, glad you are here
* first we get a config instance, and set the settings
* $config = HTMLPurifier_Config::createDefault();
* $config->set('Core.Encoding', $this->config->get('purifier.encoding'));
* $config->set('Cache.SerializerPath', $this->config->get('purifier.cachePath'));
* if ( ! $this->config->get('purifier.finalize')) {
* $config->autoFinalize = false;
* }
* $config->load [$this->getConfig());
*
* You must NOT delete the default settings
* anything in settings should be compacted with params that needed to instance HTMLPurifier_Config.
*
* @link http://htmlpurifier.org/live/configdoc/plain.html
*/
declare(strict_types=1);
// phpcs:disable Generic.Files.LineLength.TooLong
return [
'encoding' => 'UTF-8',
'finalize' => true,
'cachePath' => storage_path('app/purifier'),
'cacheFileMode' => 0755,
'settings' => [
'default' => [
'HTML.SafeIframe' => true,
// * For iframes
'URI.SafeIframeRegexp' => '%^(http://|https://|//)(www.youtube.com/embed/|player.vimeo.com/video/|vimeo.com/)%',
'HTML.Doctype' => 'HTML 4.01 Transitional',
// * Legacy filter en full avec safe legacy.
// phpcs:ignore Generic.Files.LineLength.MaxExceeded
'HTML.Allowed' => 'h1[style],h2[style],h3[style],h4[style],h5[style],h6[style],figure[class|style],figcaption,oembed[url],div[style|class],blockquote,b,strong,i,em,u,s,a[href|title|target|rel|data-model],ul[style|data-type],ol[style],dl[style],li[style|data-type],label,input[type|checked|disabled],p[style],br,span[style|class],img[width|height|alt|src|style|class|title],iframe[src|width|height|class|frameborder|style|allowfullscreen],table[style|align|border|cellspacing|cellpadding|width],thead,tbody,th[colspan|align|valign|style],tr[align|valign|style],td[style|colspan|rowspan|scope|align|valign],hr,dl,dt,sub,sup',
// * Restricted basics.
// 'HTML.Allowed' => 'br,p[style],b[style],u[style],i[style],a[href|title|target|rel],strong[style],span[class],ul[style],ol[style],li[style],h2[style],h3[style],h4[style],hr,blockquote[style]',
// * Legacy filter en full avec safe legacy.
// phpcs:ignore Generic.Files.LineLength.MaxExceeded
'CSS.AllowedProperties' => 'font,font-size,font-weight,font-style,font-family,text-decoration,padding-left,margin,margin-left,margin-right,color,background-color,text-align,list-style-type,float,width,height,vertical-align,border-color',
// * Restricted basics.
// 'CSS.AllowedProperties' => 'text-align,margin-left',
'AutoFormat.AutoParagraph' => false,
'AutoFormat.RemoveEmpty' => false,
'AutoFormat.RemoveEmpty.RemoveNbsp' => false,
],
'custom_definition' => [
'id' => 'html5-definitions',
'rev' => 2,
'debug' => true,
'elements' => [
// * http://developers.whatwg.org/sections.html
['section', 'Block', 'Flow', 'Common'],
['nav', 'Block', 'Flow', 'Common'],
['article', 'Block', 'Flow', 'Common'],
['aside', 'Block', 'Flow', 'Common'],
['header', 'Block', 'Flow', 'Common'],
['footer', 'Block', 'Flow', 'Common'],
// * Content model actually excludes several tags, not modelled here
['address', 'Block', 'Flow', 'Common'],
['hgroup', 'Block', 'Required: h1 | h2 | h3 | h4 | h5 | h6', 'Common'],
// * http://developers.whatwg.org/grouping-content.html
['figure', 'Block', 'Optional: (figcaption, Flow) | (Flow, figcaption) | Flow', 'Common'],
['figcaption', 'Inline', 'Flow', 'Common'],
['oembed', 'Inline', 'Flow', 'Common'],
// * http://developers.whatwg.org/the-video-element.html#the-video-element
['video', 'Block', 'Optional: (source, Flow) | (Flow, source) | Flow', 'Common', [
'src' => 'URI',
'type' => 'Text',
'width' => 'Length',
'height' => 'Length',
'poster' => 'URI',
'preload' => 'Enum#auto,metadata,none',
'controls' => 'Bool',
]],
['source', 'Block', 'Flow', 'Common', [
'src' => 'URI',
'type' => 'Text',
]],
// * http://developers.whatwg.org/text-level-semantics.html
['s', 'Inline', 'Inline', 'Common'],
['var', 'Inline', 'Inline', 'Common'],
['sub', 'Inline', 'Inline', 'Common'],
['sup', 'Inline', 'Inline', 'Common'],
['mark', 'Inline', 'Inline', 'Common'],
['wbr', 'Inline', 'Empty', 'Core'],
// * http://developers.whatwg.org/edits.html
['ins', 'Block', 'Flow', 'Common', ['cite' => 'URI', 'datetime' => 'CDATA']],
['del', 'Block', 'Flow', 'Common', ['cite' => 'URI', 'datetime' => 'CDATA']],
],
'attributes' => [
['iframe', 'allowfullscreen', 'Bool'],
['table', 'height', 'Text'],
['td', 'border', 'Text'],
['th', 'border', 'Text'],
['tr', 'width', 'Text'],
['tr', 'height', 'Text'],
['tr', 'border', 'Text'],
['figure', 'class', 'Text'],
['figure', 'style', 'Text'],
['oembed', 'url', 'Text'],
['span', 'style', 'Text'],
['img', 'style', 'Text'],
],
],
'custom_attributes' => [
['a', 'target', 'Enum#_blank,_self,_target,_top'],
['a', 'data-model', 'Text'],
['input', 'type', 'Text'],
['input', 'checked', 'Bool'],
['input', 'disabled', 'Bool'],
['ol', 'style', 'Text'],
['ul', 'data-type', 'Text'],
['li', 'data-type', 'Text'],
['figure', 'style', 'Text'],
['img', 'style', 'Text'],
['span', 'style', 'Text'],
['oembed', 'url', 'Text'],
],
'custom_elements' => [
['u', 'Inline', 'Inline', 'Common'],
['oembed', 'Inline', 'Inline', 'Common'],
['label', 'Inline', 'Flow', 'Common'],
['input', 'Inline', 'Empty', 'Common'],
],
],
];

131
config/queue.php Normal file
View file

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
return [
/*
*--------------------------------------------------------------------------
* Default Queue Connection Name
*--------------------------------------------------------------------------
*
* Laravel's queue supports a variety of backends via a single, unified
* API, giving you convenient access to each backend using identical
* syntax for each. The default queue connection is defined below.
*
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
*--------------------------------------------------------------------------
* Queue Connections
*--------------------------------------------------------------------------
*
* Here you may configure the connection options for every queue backend
* used by your application. An example configuration is provided for
* each backend supported by Laravel. You're also free to add more.
*
* Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
* "deferred", "background", "failover", "null"
*
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
*--------------------------------------------------------------------------
* Job Batching
*--------------------------------------------------------------------------
*
* The following options configure the database and table that store job
* batching information. These options can be updated to any database
* connection and table which has been defined by your application.
*
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
*--------------------------------------------------------------------------
* Failed Queue Jobs
*--------------------------------------------------------------------------
*
* These options configure the behavior of failed queue job logging so you
* can control how and where failed jobs are stored. Laravel ships with
* support for storing failed jobs in a simple file or in a database.
*
* Supported drivers: "database-uuids", "dynamodb", "file", "null"
*
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];

343
config/saml2.php Normal file
View file

@ -0,0 +1,343 @@
<?php
declare(strict_types=1);
use Slides\Saml2\Models\Tenant;
return [
/**
*--------------------------------------------------------------------------
* Tenant Model
*--------------------------------------------------------------------------
*
* This will allow you to override the tenant model with your own.
*/
'tenantModel' => Tenant::class,
/**
*--------------------------------------------------------------------------
* Use built-in routes
*--------------------------------------------------------------------------
*
* If "useRoutes" set to true, the package defines five new routes:
*
* Method | URI | Name
* -------|---------------------------------|------------------
* POST | {routesPrefix}/{uuid}/acs | saml.acs
* GET | {routesPrefix}/{uuid}/login | saml.login
* GET | {routesPrefix}/{uuid}/logout | saml.logout
* GET | {routesPrefix}/{uuid}/metadata | saml.metadata
* GET | {routesPrefix}/{uuid}/sls | saml.sls
*/
'useRoutes' => true,
/**
*--------------------------------------------------------------------------
* Built-in routes prefix
*--------------------------------------------------------------------------
*
* Here you may define the prefix for built-in routes.
*/
'routesPrefix' => '/saml2',
/**
*--------------------------------------------------------------------------
* Middle groups to use for the SAML routes
*--------------------------------------------------------------------------
*
* Note, Laravel 5.2 requires a group which includes StartSession
*/
'routesMiddleware' => ['saml'],
/**
*--------------------------------------------------------------------------
* Signature validation
*--------------------------------------------------------------------------
*
* Set to true if you want to use parameters from $_SERVER to validate the signature.
*/
'retrieveParametersFromServer' => false,
/**
*--------------------------------------------------------------------------
* Login redirection URL.
*--------------------------------------------------------------------------
*
* The redirection URL after successful login.
*/
'loginRoute' => '/admin/login',
/**
*--------------------------------------------------------------------------
* Logout redirection URL.
*--------------------------------------------------------------------------
*
* The redirection URL after successful logout.
*/
'logoutRoute' => '/admin/login',
/**
*--------------------------------------------------------------------------
* Login error redirection URL.
*--------------------------------------------------------------------------
*
* The redirection URL after login failing.
*/
'errorRoute' => '/admin/login',
/**
*--------------------------------------------------------------------------
* Strict mode.
*--------------------------------------------------------------------------
*
* If 'strict' is True, then the PHP Toolkit will reject unsigned
* or unencrypted messages if it expects them signed or encrypted
* Also will reject the messages if not strictly follow the SAML
* standard: Destination, NameId, Conditions... are validated too.
*/
'strict' => true,
/**
*--------------------------------------------------------------------------
* Debug mode.
*--------------------------------------------------------------------------
*
* When enabled, errors must be printed.
*/
'debug' => env('SAML2_DEBUG', env('APP_DEBUG', false)),
/**
*--------------------------------------------------------------------------
* Whether to use `X-Forwarded-*` headers to determine port/domain/protocol.
*--------------------------------------------------------------------------
*
* If 'proxyVars' is True, then the Saml lib will trust proxy headers
* e.g X-Forwarded-Proto / HTTP_X_FORWARDED_PROTO. This is useful if
* your application is running behind a load balancer which terminates SSL.
*/
'proxyVars' => true,
/**
*--------------------------------------------------------------------------
* Service Provider configuration.
*--------------------------------------------------------------------------
*
* General setting of the service provider.
*/
'sp' => [
/**
*--------------------------------------------------------------------------
* NameID format.
*--------------------------------------------------------------------------
*
* Specifies constraints on the name identifier to be used to
* represent the requested subject.
*/
'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
/**
*--------------------------------------------------------------------------
* SP Certificates.
*--------------------------------------------------------------------------
*
* Usually x509cert and privateKey of the SP are provided by files placed at
* the certs folder. But we can also provide them with the following parameters.
*/
'x509cert' => env('SAML2_SP_CERT_x509', ''),
'privateKey' => env('SAML2_SP_CERT_PRIVATEKEY', ''),
/**
*--------------------------------------------------------------------------
* Identifier (URI) of the SP entity.
*--------------------------------------------------------------------------
*
* Leave blank to use the 'saml.metadata' route.
*/
'entityId' => env('SAML2_SP_ENTITYID', ''),
/**
*--------------------------------------------------------------------------
* The Assertion Consumer Service (ACS) URL.
*--------------------------------------------------------------------------
*
* URL Location where the <Response> from the IdP will be returned, using HTTP-POST binding.
* Leave blank to use the 'saml.acs' route.
*/
'assertionConsumerService' => [
'url' => '',
],
/**
*--------------------------------------------------------------------------
* The Single Logout Service URL.
*--------------------------------------------------------------------------
*
* Specifies info about where and how the <Logout Response> message MUST be
* returned to the requester, in this case our SP.
*
* URL Location where the <Response> from the IdP will be returned, using HTTP-Redirect binding.
* Leave blank to use the 'saml.sls' route.
*/
'singleLogoutService' => [
'url' => '',
],
],
/**
*--------------------------------------------------------------------------
* OneLogin security settings.
*--------------------------------------------------------------------------
*/
'security' => [
'signatureAlgorithm' => 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256',
'digestAlgorithm' => 'http://www.w3.org/2001/04/xmlenc#sha256',
/**
*--------------------------------------------------------------------------
* NameId encryption
*--------------------------------------------------------------------------
*
* Indicates that the nameID of the <samlp:logoutRequest> sent by this SP
* will be encrypted.
*/
'nameIdEncrypted' => true,
/**
*--------------------------------------------------------------------------
* AuthnRequest signage
*--------------------------------------------------------------------------
*
* Indicates whether the <samlp:AuthnRequest> messages sent by
* this SP will be signed. The Metadata of the SP will offer this info
*/
'authnRequestsSigned' => true,
/**
*--------------------------------------------------------------------------
* Logout request signage
*--------------------------------------------------------------------------
*
* Indicates whether the <samlp:logoutRequest> messages sent by this SP
* will be signed.
*/
'logoutRequestSigned' => true,
/**
*--------------------------------------------------------------------------
* Logout response signage
*--------------------------------------------------------------------------
*
* Indicates whether the <samlp:logoutResponse> messages sent by this SP
* will be signed.
*/
'logoutResponseSigned' => true,
/**
*--------------------------------------------------------------------------
* Whether need to sign metadata.
*--------------------------------------------------------------------------
*
* The possible values:
* - false
* - true (use certs)
* - array:
* ```
* [
* 'keyFileName' => 'metadata.key',
* 'certFileName' => 'metadata.crt'
* ]
* ```
*/
'signMetadata' => true,
/**
*--------------------------------------------------------------------------
* Requirement to sign messages.
*--------------------------------------------------------------------------
*
* Indicates a requirement for the <samlp:Response>, <samlp:LogoutRequest> and
* <samlp:LogoutResponse> elements received by this SP to be signed.
*/
'wantMessagesSigned' => true,
/**
*--------------------------------------------------------------------------
* Requirement to sign assertion elements.
*--------------------------------------------------------------------------
*
* Indicates a requirement for the <saml:Assertion> elements received by
* this SP to be signed.
*/
'wantAssertionsSigned' => true,
/**
*--------------------------------------------------------------------------
* Requirement to encrypt NameID.
*--------------------------------------------------------------------------
*
* Indicates a requirement for the NameID received by this SP to be encrypted.
*/
'wantNameIdEncrypted' => false,
/**
*--------------------------------------------------------------------------
* Authentication context.
*--------------------------------------------------------------------------
*
* Set to false and no AuthContext will be sent in the AuthNRequest,
*
* Set true or don't present this parameter and you will get an
* AuthContext 'exact' 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'
*
* Set an array with the possible auth context values:
* ['urn:oasis:names:tc:SAML:2.0:ac:classes:Password', 'urn:oasis:names:tc:SAML:2.0:ac:classes:X509']
*/
'requestedAuthnContext' => true,
],
/**
*--------------------------------------------------------------------------
* Contact information.
*--------------------------------------------------------------------------
*
* It is recommended to supply a technical and support contacts.
*/
'contactPerson' => [
'technical' => [
'givenName' => env('SAML2_CONTACT_TECHNICAL_NAME', env('APP_NAME', 'Technical')),
'emailAddress' => env('SAML2_CONTACT_TECHNICAL_EMAIL', 'no@reply.com'),
],
'support' => [
'givenName' => env('SAML2_CONTACT_SUPPORT_NAME', env('APP_NAME', 'Support')),
'emailAddress' => env('SAML2_CONTACT_SUPPORT_EMAIL', 'no@reply.com'),
],
],
/**
*--------------------------------------------------------------------------
* Organization information.
*--------------------------------------------------------------------------
*
* The info in en_US lang is recommended, add more if required.
*/
'organization' => [
'en-US' => [
'name' => env('SAML2_ORGANIZATION_NAME', env('APP_NAME', 'Name')),
'displayname' => env('SAML2_ORGANIZATION_NAME', env('APP_NAME', 'Name')),
'url' => env('SAML2_ORGANIZATION_URL', env('APP_URL', 'http://url')),
],
],
/**
* Default tenant UUID.
*/
'default_tenant_uuid' => env('SAML2_DEFAULT_TENANT_UUID', null),
/**
* Target domain.
*/
'domain' => env('SAML2_DOMAIN', ''),
];

212
config/scout.php Normal file
View file

@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Default Search Engine
|--------------------------------------------------------------------------
|
| This option controls the default search connection that gets used while
| using Laravel Scout. This connection is used when syncing all models
| to the search service. You should adjust this based on your needs.
|
| Supported: "algolia", "meilisearch", "typesense",
| "database", "collection", "null"
|
*/
'driver' => env('SCOUT_DRIVER', 'collection'),
/*
|--------------------------------------------------------------------------
| Index Prefix
|--------------------------------------------------------------------------
|
| Here you may specify a prefix that will be applied to all search index
| names used by Scout. This prefix may be useful if you have multiple
| "tenants" or applications sharing the same search infrastructure.
|
*/
'prefix' => env('SCOUT_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Queue Data Syncing
|--------------------------------------------------------------------------
|
| This option allows you to control if the operations that sync your data
| with your search engines are queued. When this is set to "true" then
| all automatic data syncing will get queued for better performance.
|
*/
'queue' => env('SCOUT_QUEUE', false),
/*
|--------------------------------------------------------------------------
| Database Transactions
|--------------------------------------------------------------------------
|
| This configuration option determines if your data will only be synced
| with your search indexes after every open database transaction has
| been committed, thus preventing any discarded data from syncing.
|
*/
'after_commit' => false,
/*
|--------------------------------------------------------------------------
| Chunk Sizes
|--------------------------------------------------------------------------
|
| These options allow you to control the maximum chunk size when you are
| mass importing data into the search engine. This allows you to fine
| tune each of these chunk sizes based on the power of the servers.
|
*/
'chunk' => [
'searchable' => 500,
'unsearchable' => 500,
],
/*
|--------------------------------------------------------------------------
| Soft Deletes
|--------------------------------------------------------------------------
|
| This option allows to control whether to keep soft deleted records in
| the search indexes. Maintaining soft deleted records can be useful
| if your application still needs to search for the records later.
|
*/
'soft_delete' => false,
/*
|--------------------------------------------------------------------------
| Identify User
|--------------------------------------------------------------------------
|
| This option allows you to control whether to notify the search engine
| of the user performing the search. This is sometimes useful if the
| engine supports any analytics based on this application's users.
|
| Supported engines: "algolia"
|
*/
'identify' => env('SCOUT_IDENTIFY', false),
/*
|--------------------------------------------------------------------------
| Algolia Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Algolia settings. Algolia is a cloud hosted
| search engine which works great with Scout out of the box. Just plug
| in your application ID and admin API key to get started searching.
|
*/
'algolia' => [
'id' => env('ALGOLIA_APP_ID', ''),
'secret' => env('ALGOLIA_SECRET', ''),
'index-settings' => [
// 'users' => [
// 'searchableAttributes' => ['id', 'name', 'email'],
// 'attributesForFaceting'=> ['filterOnly(email)'],
// ],
],
],
/*
|--------------------------------------------------------------------------
| Meilisearch Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Meilisearch settings. Meilisearch is an open
| source search engine with minimal configuration. Below, you can state
| the host and key information for your own Meilisearch installation.
|
| See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options
|
*/
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
'key' => env('MEILISEARCH_KEY'),
'index-settings' => [
// 'users' => [
// 'filterableAttributes'=> ['id', 'name', 'email'],
// ],
],
],
/*
|--------------------------------------------------------------------------
| Typesense Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Typesense settings. Typesense is an open
| source search engine using minimal configuration. Below, you will
| state the host, key, and schema configuration for the instance.
|
*/
'typesense' => [
'client-settings' => [
'api_key' => env('TYPESENSE_API_KEY', 'xyz'),
'nodes' => [
[
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'path' => env('TYPESENSE_PATH', ''),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
],
'nearest_node' => [
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'path' => env('TYPESENSE_PATH', ''),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2),
'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30),
'num_retries' => env('TYPESENSE_NUM_RETRIES', 3),
'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1),
],
// 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000),
'model-settings' => [
// User::class => [
// 'collection-schema' => [
// 'fields' => [
// [
// 'name' => 'id',
// 'type' => 'string',
// ],
// [
// 'name' => 'name',
// 'type' => 'string',
// ],
// [
// 'name' => 'created_at',
// 'type' => 'int64',
// ],
// ],
// 'default_sorting_field' => 'created_at',
// ],
// 'search-parameters' => [
// 'query_by' => 'name'
// ],
// ],
],
'import_action' => env('TYPESENSE_IMPORT_ACTION', 'upsert'),
],
];

65
config/sentry.php Normal file
View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
use Uwil\LaravelCommon\Helpers\ExceptionsHelper;
return [
'dsn' => env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')),
// The release version of your application.
// Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')).
'release' => env('SENTRY_RELEASE'),
// When left empty or `null` the Laravel environment will be used.
'environment' => env('SENTRY_ENVIRONMENT'),
'breadcrumbs' => [
// Capture Laravel logs in breadcrumbs.
'logs' => true,
// Capture SQL queries in breadcrumbs.
'sql_queries' => true,
// Capture bindings on SQL queries logged in breadcrumbs.
'sql_bindings' => false,
// Capture queue job information in breadcrumbs.
'queue_info' => true,
// Capture command information in breadcrumbs.
'command_info' => true,
],
'tracing' => [
// Trace queue jobs as their own transactions.
'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', false),
// Capture queue jobs as spans when executed on the sync driver.
'queue_jobs' => true,
// Capture SQL queries as spans.
'sql_queries' => true,
// Try to find out where the SQL query originated from and add it to the query spans.
'sql_origin' => true,
// Capture views as spans.
'views' => true,
// Indicates if the tracing integrations supplied by Sentry should be loaded.
'default_integrations' => true,
// Indicates that requests without a matching route should be traced.
'missing_routes' => false,
],
// See: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send-default-pii.
'send_default_pii' => env('SENTRY_SEND_DEFAULT_PII', false),
// See: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces-sample-rate.
'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_TRACES_SAMPLE_RATE'),
'before_send' => [ExceptionsHelper::class, 'sentryEventHandler'],
];

47
config/services.php Normal file
View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
return [
/*
*--------------------------------------------------------------------------
* Third Party Services
*--------------------------------------------------------------------------
*
* This file is for storing the credentials for third party services such
* as Mailgun, Postmark, AWS and more. This file provides the de facto
* location for this type of information, allowing packages to have
* a conventional file to locate the various service credentials.
*
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
'google' => [
'recaptcha' => [
'public' => env('GOOGLE_RECAPTCHA_PUBLIC'),
'private' => env('GOOGLE_RECAPTCHA_PRIVATE'),
'version' => env('GOOGLE_RECAPTCHA_VERSION', '3') === '3' ? true : false,
],
],
];

219
config/session.php Normal file
View file

@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Str;
return [
/*
*--------------------------------------------------------------------------
* Default Session Driver
*--------------------------------------------------------------------------
*
* This option determines the default session driver that is utilized for
* incoming requests. Laravel supports a variety of storage options to
* persist session data. Database storage is a great default choice.
*
* Supported: "file", "cookie", "database", "memcached",
* "redis", "dynamodb", "array"
*
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
*--------------------------------------------------------------------------
* Session Lifetime
*--------------------------------------------------------------------------
*
* Here you may specify the number of minutes that you wish the session
* to be allowed to remain idle before it expires. If you want them
* to expire immediately when the browser is closed then you may
* indicate that via the expire_on_close configuration option.
*
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
*--------------------------------------------------------------------------
* Session Encryption
*--------------------------------------------------------------------------
*
* This option allows you to easily specify that all of your session data
* should be encrypted before it's stored. All encryption is performed
* automatically by Laravel and you may use the session like normal.
*
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
*--------------------------------------------------------------------------
* Session File Location
*--------------------------------------------------------------------------
*
* When utilizing the "file" session driver, the session files are placed
* on disk. The default storage location is defined here; however, you
* are free to provide another location where they should be stored.
*
*/
'files' => storage_path('framework/sessions'),
/*
*--------------------------------------------------------------------------
* Session Database Connection
*--------------------------------------------------------------------------
*
* When using the "database" or "redis" session drivers, you may specify a
* connection that should be used to manage these sessions. This should
* correspond to a connection in your database configuration options.
*
*/
'connection' => env('SESSION_CONNECTION'),
/*
*--------------------------------------------------------------------------
* Session Database Table
*--------------------------------------------------------------------------
*
* When using the "database" session driver, you may specify the table to
* be used to store sessions. Of course, a sensible default is defined
* for you; however, you're welcome to change this to another table.
*
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
*--------------------------------------------------------------------------
* Session Cache Store
*--------------------------------------------------------------------------
*
* When using one of the framework's cache driven session backends, you may
* define the cache store which should be used to store the session data
* between requests. This must match one of your defined cache stores.
*
* Affects: "dynamodb", "memcached", "redis"
*
*/
'store' => env('SESSION_STORE'),
/*
*--------------------------------------------------------------------------
* Session Sweeping Lottery
*--------------------------------------------------------------------------
*
* Some session drivers must manually sweep their storage location to get
* rid of old sessions from storage. Here are the chances that it will
* happen on a given request. By default, the odds are 2 out of 100.
*
*/
'lottery' => [2, 100],
/*
*--------------------------------------------------------------------------
* Session Cookie Name
*--------------------------------------------------------------------------
*
* Here you may change the name of the session cookie that is created by
* the framework. Typically, you should not need to change this value
* since doing so does not grant a meaningful security improvement.
*
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')) . '-session'
),
/*
*--------------------------------------------------------------------------
* Session Cookie Path
*--------------------------------------------------------------------------
*
* The session cookie path determines the path for which the cookie will
* be regarded as available. Typically, this will be the root path of
* your application, but you're free to change this when necessary.
*
*/
'path' => env('SESSION_PATH', '/'),
/*
*--------------------------------------------------------------------------
* Session Cookie Domain
*--------------------------------------------------------------------------
*
* This value determines the domain and subdomains the session cookie is
* available to. By default, the cookie will be available to the root
* domain without subdomains. Typically, this shouldn't be changed.
*
*/
'domain' => env('SESSION_DOMAIN'),
/*
*--------------------------------------------------------------------------
* HTTPS Only Cookies
*--------------------------------------------------------------------------
*
* By setting this option to true, session cookies will only be sent back
* to the server if the browser has a HTTPS connection. This will keep
* the cookie from being sent to you when it can't be done securely.
*
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
*--------------------------------------------------------------------------
* HTTP Access Only
*--------------------------------------------------------------------------
*
* Setting this value to true will prevent JavaScript from accessing the
* value of the cookie and the cookie will only be accessible through
* the HTTP protocol. It's unlikely you should disable this option.
*
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
*--------------------------------------------------------------------------
* Same-Site Cookies
*--------------------------------------------------------------------------
*
* This option determines how your cookies behave when cross-site requests
* take place, and can be used to mitigate CSRF attacks. By default, we
* will set this value to "lax" to permit secure cross-site requests.
*
* See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
*
* Supported: "lax", "strict", "none", null
*
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
*--------------------------------------------------------------------------
* Partitioned Cookies
*--------------------------------------------------------------------------
*
* Setting this value to true will tie the cookie to the top-level site for
* a cross-site context. Partitioned cookies are accepted by the browser
* when flagged "secure" and the Same-Site attribute is set to "none".
*
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
];

170
config/telescope.php Normal file
View file

@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
use Laravel\Telescope\Http\Middleware\Authorize;
use Laravel\Telescope\Watchers;
return [
/**
*--------------------------------------------------------------------------
* Telescope Master Switch
*--------------------------------------------------------------------------
*
* This option may be used to disable all Telescope watchers regardless
* of their individual configuration, which simply provides a single
* and convenient way to enable or disable Telescope data storage.
*/
'enabled' => env('TELESCOPE_ENABLED', false),
/**
*--------------------------------------------------------------------------
* Telescope Domain
*--------------------------------------------------------------------------
*
* This is the subdomain where Telescope will be accessible from. If the
* setting is null, Telescope will reside under the same domain as the
* application. Otherwise, this value will be used as the subdomain.
*/
'domain' => env('TELESCOPE_DOMAIN'),
/**
*--------------------------------------------------------------------------
* Telescope Path
*--------------------------------------------------------------------------
*
* This is the URI path where Telescope will be accessible from. Feel free
* to change this path to anything you like. Note that the URI will not
* affect the paths of its internal API that aren't exposed to users.
*/
'path' => env('TELESCOPE_PATH', 'admin/telescope'),
/**
*--------------------------------------------------------------------------
* Telescope Storage Driver
*--------------------------------------------------------------------------
*
* This configuration options determines the storage driver that will
* be used to store Telescope's data. In addition, you may set any
* custom options as needed by the particular driver you choose.
*/
'driver' => env('TELESCOPE_DRIVER', 'database'),
'storage' => [
'database' => [
'connection' => env('DB_CONNECTION', 'mysql'),
'chunk' => 1000,
],
],
/**
*--------------------------------------------------------------------------
* Telescope Queue
*--------------------------------------------------------------------------
*
* This configuration options determines the queue connection and queue
* which will be used to process ProcessPendingUpdate jobs. This can
* be changed if you would prefer to use a non-default connection.
*/
'queue' => [
'connection' => env('TELESCOPE_QUEUE_CONNECTION', null),
'queue' => env('TELESCOPE_QUEUE', null),
],
/**
*--------------------------------------------------------------------------
* Telescope Route Middleware
*--------------------------------------------------------------------------
*
* These middleware will be assigned to every Telescope route, giving you
* the chance to add your own middleware to this list or change any of
* the existing middleware. Or, you can simply stick with this list.
*/
'middleware' => [
Authorize::class,
],
/**
*--------------------------------------------------------------------------
* Allowed / Ignored Paths & Commands
*--------------------------------------------------------------------------
*
* The following array lists the URI paths and Artisan commands that will
* not be watched by Telescope. In addition to this list, some Laravel
* commands, like migrations and queue commands, are always ignored.
*/
'only_paths' => [],
'ignore_paths' => [
'livewire*',
'nova-api*',
'pulse*',
],
'ignore_commands' => [],
/**
*--------------------------------------------------------------------------
* Telescope Watchers
*--------------------------------------------------------------------------
*
* The following array lists the "watchers" that will be registered with
* Telescope. The watchers gather the application's profile data when
* a request or task is executed. Feel free to customize this list.
*/
'watchers' => [
Watchers\BatchWatcher::class => env('TELESCOPE_BATCH_WATCHER', true),
Watchers\CacheWatcher::class => [
'enabled' => env('TELESCOPE_CACHE_WATCHER', true),
'hidden' => [],
],
Watchers\ClientRequestWatcher::class => env('TELESCOPE_CLIENT_REQUEST_WATCHER', true),
Watchers\CommandWatcher::class => [
'enabled' => env('TELESCOPE_COMMAND_WATCHER', true),
'ignore' => [],
],
Watchers\DumpWatcher::class => [
'enabled' => env('TELESCOPE_DUMP_WATCHER', true),
'always' => env('TELESCOPE_DUMP_WATCHER_ALWAYS', false),
],
Watchers\EventWatcher::class => [
'enabled' => env('TELESCOPE_EVENT_WATCHER', true),
'ignore' => [],
],
Watchers\ExceptionWatcher::class => env('TELESCOPE_EXCEPTION_WATCHER', true),
Watchers\GateWatcher::class => [
'enabled' => env('TELESCOPE_GATE_WATCHER', true),
'ignore_abilities' => [],
'ignore_packages' => true,
'ignore_paths' => [],
],
Watchers\JobWatcher::class => env('TELESCOPE_JOB_WATCHER', true),
Watchers\LogWatcher::class => [
'enabled' => env('TELESCOPE_LOG_WATCHER', true),
'level' => 'error',
],
Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true),
Watchers\ModelWatcher::class => [
'enabled' => env('TELESCOPE_MODEL_WATCHER', true),
'events' => ['eloquent.*'],
'hydrations' => true,
],
Watchers\NotificationWatcher::class => env('TELESCOPE_NOTIFICATION_WATCHER', true),
Watchers\QueryWatcher::class => [
'enabled' => env('TELESCOPE_QUERY_WATCHER', true),
'ignore_packages' => true,
'ignore_paths' => [],
'slow' => 100,
],
Watchers\RedisWatcher::class => env('TELESCOPE_REDIS_WATCHER', true),
Watchers\RequestWatcher::class => [
'enabled' => env('TELESCOPE_REQUEST_WATCHER', true),
'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64),
'ignore_http_methods' => [],
'ignore_status_codes' => [],
],
Watchers\ScheduleWatcher::class => env('TELESCOPE_SCHEDULE_WATCHER', true),
Watchers\ViewWatcher::class => env('TELESCOPE_VIEW_WATCHER', true),
],
];

64
config/uwil-backend.php Normal file
View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use Uwil\LaravelBackend\Models\User;
use Uwil\LaravelBackend\Requests\Users\StoreUserRequest;
use Uwil\LaravelBackend\Requests\Users\UpdateUserRequest;
return [
/**
*--------------------------------------------------------------------------
* Routes
*--------------------------------------------------------------------------
*
* This option may be used to edit routes informations.
* - prefix: string.
* - name: string.
*/
'routes' => [
'prefix' => 'admin',
'name' => 'back.',
],
/**
*--------------------------------------------------------------------------
* Redirects
*--------------------------------------------------------------------------
*
* This option may be used to edit redirects.
* - redirect_logout: string.
*/
'redirect_logout' => 'front.dynamic-pages.accueil.fr',
/**
*--------------------------------------------------------------------------
* Saml
*--------------------------------------------------------------------------
*
* This option may be used to active or not the saml connection.
* - enabled: boolean.
*/
'saml' => [
'enabled' => true,
],
/**
*--------------------------------------------------------------------------
* User
*--------------------------------------------------------------------------
*
* This options may be used when overriding the User model.
* - model: class.
* - store: class.
* - update: class.
*/
'user' => [
'model' => User::class,
'requests' => [
'store' => StoreUserRequest::class,
'update' => UpdateUserRequest::class,
],
],
];

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
return [
/**
*--------------------------------------------------------------------------
* Path of the default view.
*--------------------------------------------------------------------------
*
* This option may be used to edit routes informations.
* - default-view: string.
*/
'default-view' => 'front/Pages/DynamicPageDefault',
];

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Product;
use Domain\Models\ValueObjects\Products\BreedingMethod;
use Domain\Models\ValueObjects\Products\Business;
use Domain\Models\ValueObjects\Products\NutritionalValues;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Domain\Models\ValueObjects\Products\ProductFormat;
use Domain\Models\ValueObjects\Products\Technology;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Product> */
class ProductFactory extends Factory
{
protected $model = Product::class;
public function definition(): array
{
return [
'seo_title' => $this->faker->sentence(4),
'seo_description' => $this->faker->text(150),
'picture' => $this->faker->imageUrl(800, 600, 'food'),
'picture_alt' => $this->faker->words(3, true),
'picture_title' => $this->faker->words(3, true),
'name' => ucfirst($this->faker->words(3, true)),
'category' => $this->faker->randomElement(ProductCategory::cases()),
'technology' => $this->faker->randomElement(Technology::cases()),
'breeding_methods' => $this->faker->randomElements(
BreedingMethod::cases(),
$this->faker->numberBetween(1, 3)
),
'businesses' => $this->faker->randomElements(
Business::cases(),
$this->faker->numberBetween(1, count(Business::cases()))
),
'is_egalim' => $this->faker->boolean(30),
'formats' => $this->faker->randomElements(
ProductFormat::cases(),
$this->faker->numberBetween(1, 3)
),
'description' => $this->faker->paragraph(),
'details_and_advantages' => $this->faker->paragraphs(2, true),
'handling_advices' => $this->faker->paragraph(),
'ingredients' => $this->faker->optional(0.8)->paragraph(),
'conservation_before_opening' => 'À conserver entre 0°C et 4°C.',
'conservation_after_opening' => $this->faker->optional(0.7)->sentence(),
'nutritional_values' => $this->faker->boolean(80) ? new NutritionalValues(
refWeightG: 100.0,
kj: $this->faker->randomFloat(2, 0, 3000),
kcal: $this->faker->randomFloat(2, 0, 800),
fatG: $this->faker->randomFloat(2, 0, 100),
satFatG: $this->faker->randomFloat(2, 0, 50),
carbG: $this->faker->randomFloat(2, 0, 100),
sugarG: $this->faker->randomFloat(2, 0, 100),
fiberG: $this->faker->randomFloat(2, 0, 50),
proteinG: $this->faker->randomFloat(2, 0, 100),
saltG: $this->faker->randomFloat(2, 0, 10),
) : null,
'pdf' => $this->faker->optional(0.5)->filePath(),
'inco_code' => $this->faker->optional(0.5)->bothify('INCO-####'),
];
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
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\Factories\Factory;
class RecipeFactory extends Factory
{
protected $model = Recipe::class;
public function definition(): array
{
return [
'seo_title' => $this->faker->sentence(4),
'seo_description' => $this->faker->text(150),
'picture' => $this->faker->imageUrl(),
'picture_alt' => $this->faker->words(3, true),
'picture_title' => $this->faker->words(3, true),
'name' => $this->faker->words(3, true),
'product_category' => $this->faker->randomElement(ProductCategory::cases()),
'consomption_time' => $this->faker->randomElement(ConsumptionTime::cases()),
'inspiration' => $this->faker->randomElement(Inspiration::cases()),
'season' => $this->faker->randomElement(Season::cases()),
'description' => $this->faker->paragraph(),
'details' => 'Pour ' . $this->faker->numberBetween(1, 10) . " personnes\n"
. 'À réaliser en ' . $this->faker->numberBetween(15, 60) . ' minutes',
'ingredients_list' => $this->faker->paragraph(),
'preparation_steps' => $this->faker->text(),
'tips_and_advices' => $this->faker->optional()->paragraph(),
];
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class () extends Migration
{
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('seo_title');
$table->string('seo_description');
$table->string('picture');
$table->string('picture_alt');
$table->string('picture_title');
$table->string('name');
$table->string('slug')->unique();
$table->string('category')->comment('Domain\Models\ValueObjects\Products\ProductRange');
$table->string('technology')->comment('Domain\Models\ValueObjects\Products\Technology');
$table->boolean('is_egalim')->default(false);
$table->json('breeding_methods')->comment('Array of Domain\Models\ValueObjects\Products\BreedingMethod');
$table->json('businesses')->comment('Array of Domain\Models\ValueObjects\Products\Business');
$table->json('formats')->comment('Array of Domain\Models\ValueObjects\Products\ProductFormat');
$table->text('description');
$table->text('details_and_advantages');
$table->text('handling_advices');
$table->text('ingredients')->nullable();
$table->text('conservation_before_opening');
$table->text('conservation_after_opening')->nullable();
$table->json('nutritional_values')->nullable()->comment('Domain\Models\ValueObjects\NutritionalValues');
$table->string('pdf')->nullable();
$table->softDeletes();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('products');
}
};

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class () extends Migration
{
public function up(): void
{
Schema::create('recipes', function (Blueprint $table) {
$table->uuid('id')->primary();
// SEO & Medias
$table->string('seo_title');
$table->string('seo_description');
$table->string('picture');
$table->string('picture_alt');
$table->string('picture_title');
$table->string('name');
$table->string('slug')->unique();
$table->string('product_category')->comment('Domain\Models\ValueObjects\Products\ProductRange');
$table->string('consomption_time')->comment('Domain\Models\ValueObjects\Recipes\ConsumptionTime');
$table->string('inspiration')->comment('Domain\Models\ValueObjects\Recipes\Inspiration');
$table->string('season')->comment('Domain\Models\ValueObjects\Recipes\Season');
$table->text('description')->nullable();
$table->text('details')->nullable();
$table->text('ingredients_list');
$table->text('preparation_steps');
$table->text('tips_and_advices')->nullable();
$table->softDeletes();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('recipes');
}
};

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class () extends Migration
{
public function up(): void
{
Schema::create('product_recipe', function (Blueprint $table) {
$table->foreignUuid('product_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('recipe_id')->constrained()->cascadeOnDelete();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('product_recipe');
}
};

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class () extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('activity_logs', function (Blueprint $table) {
$table->string('model_id')->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class () extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
if (! Schema::hasColumn('users', 'deleted_at')) {
$table->softDeletes()->nullable()->comment('Date the user was deleted.');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
if (Schema::hasColumn('users', 'deleted_at')) {
$table->dropSoftDeletes();
}
});
}
};

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