chore: first commit
This commit is contained in:
commit
6890147aa6
365 changed files with 36775 additions and 0 deletions
18
.editorconfig
Normal file
18
.editorconfig
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
[compose.yaml]
|
||||||
|
indent_size = 4
|
||||||
64
.env.example
Normal file
64
.env.example
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
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_HOST=127.0.0.1
|
||||||
|
REDIS_PASSWORD=null
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
MAIL_MAILER=log
|
||||||
|
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}"
|
||||||
|
|
||||||
|
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}"
|
||||||
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal 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
|
||||||
|
|
||||||
|
CHANGELOG.md export-ignore
|
||||||
|
README.md export-ignore
|
||||||
|
.github/workflows/browser-tests.yml export-ignore
|
||||||
45
.github/workflows/lint.yml
vendored
Normal file
45
.github/workflows/lint.yml
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
name: linter
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.4'
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: |
|
||||||
|
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
||||||
|
npm install
|
||||||
|
|
||||||
|
- name: Run Pint
|
||||||
|
run: composer lint
|
||||||
|
|
||||||
|
- name: Format Frontend
|
||||||
|
run: npm run format
|
||||||
|
|
||||||
|
- name: Lint Frontend
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
# - name: Commit Changes
|
||||||
|
# uses: stefanzweifel/git-auto-commit-action@v5
|
||||||
|
# with:
|
||||||
|
# commit_message: fix code style
|
||||||
|
# commit_options: '--no-verify'
|
||||||
52
.github/workflows/tests.yml
vendored
Normal file
52
.github/workflows/tests.yml
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
name: tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
php-version: ['8.4', '8.5']
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: ${{ matrix.php-version }}
|
||||||
|
tools: composer:v2
|
||||||
|
coverage: xdebug
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Install Node Dependencies
|
||||||
|
run: npm i
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||||
|
|
||||||
|
- name: Copy Environment File
|
||||||
|
run: cp .env.example .env
|
||||||
|
|
||||||
|
- name: Generate Application Key
|
||||||
|
run: php artisan key:generate
|
||||||
|
|
||||||
|
- name: Build Assets
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: ./vendor/bin/pest
|
||||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/.phpunit.cache
|
||||||
|
/bootstrap/ssr
|
||||||
|
/node_modules
|
||||||
|
/public/build
|
||||||
|
/public/hot
|
||||||
|
/public/storage
|
||||||
|
/storage/*.key
|
||||||
|
/storage/pail
|
||||||
|
/resources/js/actions
|
||||||
|
/resources/js/routes
|
||||||
|
/resources/js/wayfinder
|
||||||
|
/vendor
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.backup
|
||||||
|
.env.production
|
||||||
|
.phpactor.json
|
||||||
|
.phpunit.result.cache
|
||||||
|
Homestead.json
|
||||||
|
Homestead.yaml
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
/auth.json
|
||||||
|
/.fleet
|
||||||
|
/.idea
|
||||||
|
/.nova
|
||||||
|
/.vscode
|
||||||
|
/.zed
|
||||||
20
.mcp.json
Normal file
20
.mcp.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"laravel-boost": {
|
||||||
|
"command": "php",
|
||||||
|
"args": [
|
||||||
|
"artisan",
|
||||||
|
"boost:mcp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"herd": {
|
||||||
|
"command": "php",
|
||||||
|
"args": [
|
||||||
|
"/Applications/Herd.app/Contents/Resources/herd-mcp.phar"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"SITE_PATH": "/Users/paulocastellano/Code/social-media-scheduler"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
.prettierignore
Normal file
2
.prettierignore
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
resources/js/components/ui/*
|
||||||
|
resources/views/mail/*
|
||||||
26
.prettierrc
Normal file
26
.prettierrc
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"singleAttributePerLine": false,
|
||||||
|
"htmlWhitespaceSensitivity": "css",
|
||||||
|
"printWidth": 80,
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-organize-imports",
|
||||||
|
"prettier-plugin-tailwindcss"
|
||||||
|
],
|
||||||
|
"tailwindFunctions": [
|
||||||
|
"clsx",
|
||||||
|
"cn",
|
||||||
|
"cva"
|
||||||
|
],
|
||||||
|
"tailwindStylesheet": "resources/css/app.css",
|
||||||
|
"tabWidth": 4,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "**/*.yml",
|
||||||
|
"options": {
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
539
CLAUDE.md
Normal file
539
CLAUDE.md
Normal file
|
|
@ -0,0 +1,539 @@
|
||||||
|
<laravel-boost-guidelines>
|
||||||
|
=== foundation rules ===
|
||||||
|
|
||||||
|
# Laravel Boost Guidelines
|
||||||
|
|
||||||
|
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
||||||
|
|
||||||
|
## Foundational Context
|
||||||
|
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||||
|
|
||||||
|
- php - 8.4.16
|
||||||
|
- inertiajs/inertia-laravel (INERTIA) - v2
|
||||||
|
- laravel/cashier (CASHIER) - v16
|
||||||
|
- laravel/fortify (FORTIFY) - v1
|
||||||
|
- laravel/framework (LARAVEL) - v12
|
||||||
|
- laravel/horizon (HORIZON) - v5
|
||||||
|
- laravel/prompts (PROMPTS) - v0
|
||||||
|
- laravel/socialite (SOCIALITE) - v5
|
||||||
|
- laravel/wayfinder (WAYFINDER) - v0
|
||||||
|
- laravel/mcp (MCP) - v0
|
||||||
|
- laravel/pint (PINT) - v1
|
||||||
|
- laravel/sail (SAIL) - v1
|
||||||
|
- pestphp/pest (PEST) - v4
|
||||||
|
- phpunit/phpunit (PHPUNIT) - v12
|
||||||
|
- @inertiajs/vue3 (INERTIA) - v2
|
||||||
|
- tailwindcss (TAILWINDCSS) - v4
|
||||||
|
- vue (VUE) - v3
|
||||||
|
- @laravel/vite-plugin-wayfinder (WAYFINDER) - v0
|
||||||
|
- eslint (ESLINT) - v9
|
||||||
|
- prettier (PRETTIER) - v3
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||||
|
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||||
|
- Check for existing components to reuse before writing a new one.
|
||||||
|
|
||||||
|
## Verification Scripts
|
||||||
|
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
||||||
|
|
||||||
|
## Application Structure & Architecture
|
||||||
|
- Stick to existing directory structure; don't create new base folders without approval.
|
||||||
|
- Do not change the application's dependencies without approval.
|
||||||
|
|
||||||
|
## Frontend Bundling
|
||||||
|
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||||
|
|
||||||
|
## Replies
|
||||||
|
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||||
|
|
||||||
|
## Documentation Files
|
||||||
|
- You must only create documentation files if explicitly requested by the user.
|
||||||
|
|
||||||
|
=== boost rules ===
|
||||||
|
|
||||||
|
## Laravel Boost
|
||||||
|
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||||
|
|
||||||
|
## Artisan
|
||||||
|
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||||
|
|
||||||
|
## Tinker / Debugging
|
||||||
|
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||||
|
- Use the `database-query` tool when you only need to read from the database.
|
||||||
|
|
||||||
|
## Reading Browser Logs With the `browser-logs` Tool
|
||||||
|
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||||
|
- Only recent browser logs will be useful - ignore old logs.
|
||||||
|
|
||||||
|
## Searching Documentation (Critically Important)
|
||||||
|
- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||||
|
- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||||
|
- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
|
||||||
|
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||||
|
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
||||||
|
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||||
|
|
||||||
|
### Available Search Syntax
|
||||||
|
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
||||||
|
|
||||||
|
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||||
|
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||||
|
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||||
|
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||||
|
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||||
|
|
||||||
|
=== php rules ===
|
||||||
|
|
||||||
|
## PHP
|
||||||
|
|
||||||
|
- Always use curly braces for control structures, even if it has one line.
|
||||||
|
|
||||||
|
### Constructors
|
||||||
|
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||||
|
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||||
|
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||||
|
|
||||||
|
### Type Declarations
|
||||||
|
- Always use explicit return type declarations for methods and functions.
|
||||||
|
- Use appropriate PHP type hints for method parameters.
|
||||||
|
|
||||||
|
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||||
|
protected function isAccessible(User $user, ?string $path = null): bool
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
|
||||||
|
|
||||||
|
## PHPDoc Blocks
|
||||||
|
- Add useful array shape type definitions for arrays when appropriate.
|
||||||
|
|
||||||
|
## Enums
|
||||||
|
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||||
|
|
||||||
|
=== herd rules ===
|
||||||
|
|
||||||
|
## Laravel Herd
|
||||||
|
|
||||||
|
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs.
|
||||||
|
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
|
||||||
|
|
||||||
|
=== tests rules ===
|
||||||
|
|
||||||
|
## Test Enforcement
|
||||||
|
|
||||||
|
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||||
|
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||||
|
|
||||||
|
=== inertia-laravel/core rules ===
|
||||||
|
|
||||||
|
## Inertia
|
||||||
|
|
||||||
|
- Inertia.js components should be placed in the `resources/js/Pages` directory unless specified differently in the JS bundler (`vite.config.js`).
|
||||||
|
- Use `Inertia::render()` for server-side routing instead of traditional Blade views.
|
||||||
|
- Use the `search-docs` tool for accurate guidance on all things Inertia.
|
||||||
|
|
||||||
|
<code-snippet name="Inertia Render Example" lang="php">
|
||||||
|
// routes/web.php example
|
||||||
|
Route::get('/users', function () {
|
||||||
|
return Inertia::render('Users/Index', [
|
||||||
|
'users' => User::all()
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
=== inertia-laravel/v2 rules ===
|
||||||
|
|
||||||
|
## Inertia v2
|
||||||
|
|
||||||
|
- Make use of all Inertia features from v1 and v2. Check the documentation before making any changes to ensure we are taking the correct approach.
|
||||||
|
|
||||||
|
### Inertia v2 New Features
|
||||||
|
- Deferred props.
|
||||||
|
- Infinite scrolling using merging props and `WhenVisible`.
|
||||||
|
- Lazy loading data on scroll.
|
||||||
|
- Polling.
|
||||||
|
- Prefetching.
|
||||||
|
|
||||||
|
### Deferred Props & Empty States
|
||||||
|
- When using deferred props on the frontend, you should add a nice empty state with pulsing/animated skeleton.
|
||||||
|
|
||||||
|
### Inertia Form General Guidance
|
||||||
|
- The recommended way to build forms when using Inertia is with the `<Form>` component - a useful example is below. Use the `search-docs` tool with a query of `form component` for guidance.
|
||||||
|
- Forms can also be built using the `useForm` helper for more programmatic control, or to follow existing conventions. Use the `search-docs` tool with a query of `useForm helper` for guidance.
|
||||||
|
- `resetOnError`, `resetOnSuccess`, and `setDefaultsOnSuccess` are available on the `<Form>` component. Use the `search-docs` tool with a query of `form component resetting` for guidance.
|
||||||
|
|
||||||
|
=== laravel/core rules ===
|
||||||
|
|
||||||
|
## Do Things the Laravel Way
|
||||||
|
|
||||||
|
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||||
|
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||||
|
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||||
|
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||||
|
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||||
|
- Generate code that prevents N+1 query problems by using eager loading.
|
||||||
|
- Use Laravel's query builder for very complex database operations.
|
||||||
|
|
||||||
|
### Model Creation
|
||||||
|
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||||
|
|
||||||
|
### APIs & Eloquent Resources
|
||||||
|
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||||
|
|
||||||
|
### Controllers & Validation
|
||||||
|
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||||
|
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||||
|
|
||||||
|
### Queues
|
||||||
|
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||||
|
|
||||||
|
### URL Generation
|
||||||
|
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||||
|
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||||
|
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||||
|
|
||||||
|
### Vite Error
|
||||||
|
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||||
|
|
||||||
|
=== laravel/v12 rules ===
|
||||||
|
|
||||||
|
## Laravel 12
|
||||||
|
|
||||||
|
- Use the `search-docs` tool to get version-specific documentation.
|
||||||
|
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||||
|
|
||||||
|
### Laravel 12 Structure
|
||||||
|
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
||||||
|
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
||||||
|
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||||
|
- `bootstrap/providers.php` contains application specific service providers.
|
||||||
|
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||||
|
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||||
|
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||||
|
|
||||||
|
### Models
|
||||||
|
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||||
|
|
||||||
|
=== wayfinder/core rules ===
|
||||||
|
|
||||||
|
## Laravel Wayfinder
|
||||||
|
|
||||||
|
Wayfinder generates TypeScript functions and types for Laravel controllers and routes which you can import into your client-side code. It provides type safety and automatic synchronization between backend routes and frontend code.
|
||||||
|
|
||||||
|
### Development Guidelines
|
||||||
|
- Always use the `search-docs` tool to check Wayfinder correct usage before implementing any features.
|
||||||
|
- Always prefer named imports for tree-shaking (e.g., `import { show } from '@/actions/...'`).
|
||||||
|
- Avoid default controller imports (prevents tree-shaking).
|
||||||
|
- Run `php artisan wayfinder:generate` after route changes if Vite plugin isn't installed.
|
||||||
|
|
||||||
|
### Feature Overview
|
||||||
|
- Form Support: Use `.form()` with `--with-form` flag for HTML form attributes — `<form {...store.form()}>` → `action="/posts" method="post"`.
|
||||||
|
- HTTP Methods: Call `.get()`, `.post()`, `.patch()`, `.put()`, `.delete()` for specific methods — `show.head(1)` → `{ url: "/posts/1", method: "head" }`.
|
||||||
|
- Invokable Controllers: Import and invoke directly as functions. For example, `import StorePost from '@/actions/.../StorePostController'; StorePost()`.
|
||||||
|
- Named Routes: Import from `@/routes/` for non-controller routes. For example, `import { show } from '@/routes/post'; show(1)` for route name `post.show`.
|
||||||
|
- Parameter Binding: Detects route keys (e.g., `{post:slug}`) and accepts matching object properties — `show("my-post")` or `show({ slug: "my-post" })`.
|
||||||
|
- Query Merging: Use `mergeQuery` to merge with `window.location.search`, set values to `null` to remove — `show(1, { mergeQuery: { page: 2, sort: null } })`.
|
||||||
|
- Query Parameters: Pass `{ query: {...} }` in options to append params — `show(1, { query: { page: 1 } })` → `"/posts/1?page=1"`.
|
||||||
|
- Route Objects: Functions return `{ url, method }` shaped objects — `show(1)` → `{ url: "/posts/1", method: "get" }`.
|
||||||
|
- URL Extraction: Use `.url()` to get URL string — `show.url(1)` → `"/posts/1"`.
|
||||||
|
|
||||||
|
### Example Usage
|
||||||
|
|
||||||
|
<code-snippet name="Wayfinder Basic Usage" lang="typescript">
|
||||||
|
// Import controller methods (tree-shakable)...
|
||||||
|
import { show, store, update } from '@/actions/App/Http/Controllers/PostController'
|
||||||
|
|
||||||
|
// Get route object with URL and method...
|
||||||
|
show(1) // { url: "/posts/1", method: "get" }
|
||||||
|
|
||||||
|
// Get just the URL...
|
||||||
|
show.url(1) // "/posts/1"
|
||||||
|
|
||||||
|
// Use specific HTTP methods...
|
||||||
|
show.get(1) // { url: "/posts/1", method: "get" }
|
||||||
|
show.head(1) // { url: "/posts/1", method: "head" }
|
||||||
|
|
||||||
|
// Import named routes...
|
||||||
|
import { show as postShow } from '@/routes/post' // For route name 'post.show'
|
||||||
|
postShow(1) // { url: "/posts/1", method: "get" }
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
### Wayfinder + Inertia
|
||||||
|
If your application uses the `<Form>` component from Inertia, you can use Wayfinder to generate form action and method automatically.
|
||||||
|
<code-snippet name="Wayfinder Form Component (Vue)" lang="vue">
|
||||||
|
|
||||||
|
<Form v-bind="store.form()"><input name="title" /></Form>
|
||||||
|
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
=== pint/core rules ===
|
||||||
|
|
||||||
|
## Laravel Pint Code Formatter
|
||||||
|
|
||||||
|
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||||
|
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
|
||||||
|
|
||||||
|
=== pest/core rules ===
|
||||||
|
|
||||||
|
## Pest
|
||||||
|
### Testing
|
||||||
|
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||||
|
|
||||||
|
### Pest Tests
|
||||||
|
- All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
|
||||||
|
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
||||||
|
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||||
|
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||||
|
- Pest tests look and behave like this:
|
||||||
|
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||||
|
it('is true', function () {
|
||||||
|
expect(true)->toBeTrue();
|
||||||
|
});
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
||||||
|
- To run all tests: `php artisan test --compact`.
|
||||||
|
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||||
|
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||||
|
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
||||||
|
|
||||||
|
### Pest Assertions
|
||||||
|
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
|
||||||
|
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
||||||
|
it('returns all', function () {
|
||||||
|
$response = $this->postJson('/api/docs', []);
|
||||||
|
|
||||||
|
$response->assertSuccessful();
|
||||||
|
});
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
### Mocking
|
||||||
|
- Mocking can be very helpful when appropriate.
|
||||||
|
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
|
||||||
|
- You can also create partial mocks using the same import or self method.
|
||||||
|
|
||||||
|
### Datasets
|
||||||
|
- Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules.
|
||||||
|
|
||||||
|
<code-snippet name="Pest Dataset Example" lang="php">
|
||||||
|
it('has emails', function (string $email) {
|
||||||
|
expect($email)->not->toBeEmpty();
|
||||||
|
})->with([
|
||||||
|
'james' => 'james@laravel.com',
|
||||||
|
'taylor' => 'taylor@laravel.com',
|
||||||
|
]);
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
=== pest/v4 rules ===
|
||||||
|
|
||||||
|
## Pest 4
|
||||||
|
|
||||||
|
- Pest 4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
|
||||||
|
- Browser testing is incredibly powerful and useful for this project.
|
||||||
|
- Browser tests should live in `tests/Browser/`.
|
||||||
|
- Use the `search-docs` tool for detailed guidance on utilizing these features.
|
||||||
|
|
||||||
|
### Browser Testing
|
||||||
|
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest 4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
|
||||||
|
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
|
||||||
|
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
|
||||||
|
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
|
||||||
|
- Switch color schemes (light/dark mode) when appropriate.
|
||||||
|
- Take screenshots or pause tests for debugging when appropriate.
|
||||||
|
|
||||||
|
### Example Tests
|
||||||
|
|
||||||
|
<code-snippet name="Pest Browser Test Example" lang="php">
|
||||||
|
it('may reset the password', function () {
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
|
||||||
|
$page = visit('/sign-in'); // Visit on a real browser...
|
||||||
|
|
||||||
|
$page->assertSee('Sign In')
|
||||||
|
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
|
||||||
|
->click('Forgot Password?')
|
||||||
|
->fill('email', 'nuno@laravel.com')
|
||||||
|
->click('Send Reset Link')
|
||||||
|
->assertSee('We have emailed your password reset link!')
|
||||||
|
|
||||||
|
Notification::assertSent(ResetPassword::class);
|
||||||
|
});
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
<code-snippet name="Pest Smoke Testing Example" lang="php">
|
||||||
|
$pages = visit(['/', '/about', '/contact']);
|
||||||
|
|
||||||
|
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
=== inertia-vue/core rules ===
|
||||||
|
|
||||||
|
## Inertia + Vue
|
||||||
|
|
||||||
|
- Vue components must have a single root element.
|
||||||
|
- Use `router.visit()` or `<Link>` for navigation instead of traditional links.
|
||||||
|
|
||||||
|
<code-snippet name="Inertia Client Navigation" lang="vue">
|
||||||
|
|
||||||
|
import { Link } from '@inertiajs/vue3'
|
||||||
|
<Link href="/">Home</Link>
|
||||||
|
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
=== inertia-vue/v2/forms rules ===
|
||||||
|
|
||||||
|
## Inertia v2 + Vue Forms
|
||||||
|
|
||||||
|
<code-snippet name="`<Form>` Component Example" lang="vue">
|
||||||
|
|
||||||
|
<Form
|
||||||
|
action="/users"
|
||||||
|
method="post"
|
||||||
|
#default="{
|
||||||
|
errors,
|
||||||
|
hasErrors,
|
||||||
|
processing,
|
||||||
|
progress,
|
||||||
|
wasSuccessful,
|
||||||
|
recentlySuccessful,
|
||||||
|
setError,
|
||||||
|
clearErrors,
|
||||||
|
resetAndClearErrors,
|
||||||
|
defaults,
|
||||||
|
isDirty,
|
||||||
|
reset,
|
||||||
|
submit,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<input type="text" name="name" />
|
||||||
|
|
||||||
|
<div v-if="errors.name">
|
||||||
|
{{ errors.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" :disabled="processing">
|
||||||
|
{{ processing ? 'Creating...' : 'Create User' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="wasSuccessful">User created successfully!</div>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
=== tailwindcss/core rules ===
|
||||||
|
|
||||||
|
## Tailwind CSS
|
||||||
|
|
||||||
|
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
|
||||||
|
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
|
||||||
|
- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
|
||||||
|
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
||||||
|
|
||||||
|
### Spacing
|
||||||
|
- When listing items, use gap utilities for spacing; don't use margins.
|
||||||
|
|
||||||
|
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<div>Superior</div>
|
||||||
|
<div>Michigan</div>
|
||||||
|
<div>Erie</div>
|
||||||
|
</div>
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
### Dark Mode
|
||||||
|
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
||||||
|
|
||||||
|
=== tailwindcss/v4 rules ===
|
||||||
|
|
||||||
|
## Tailwind CSS 4
|
||||||
|
|
||||||
|
- Always use Tailwind CSS v4; do not use the deprecated utilities.
|
||||||
|
- `corePlugins` is not supported in Tailwind v4.
|
||||||
|
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
|
||||||
|
|
||||||
|
<code-snippet name="Extending Theme in CSS" lang="css">
|
||||||
|
@theme {
|
||||||
|
--color-brand: oklch(0.72 0.11 178);
|
||||||
|
}
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
|
||||||
|
|
||||||
|
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
|
||||||
|
- @tailwind base;
|
||||||
|
- @tailwind components;
|
||||||
|
- @tailwind utilities;
|
||||||
|
+ @import "tailwindcss";
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
### Replaced Utilities
|
||||||
|
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
|
||||||
|
- Opacity values are still numeric.
|
||||||
|
|
||||||
|
| Deprecated | Replacement |
|
||||||
|
|------------+--------------|
|
||||||
|
| bg-opacity-* | bg-black/* |
|
||||||
|
| text-opacity-* | text-black/* |
|
||||||
|
| border-opacity-* | border-black/* |
|
||||||
|
| divide-opacity-* | divide-black/* |
|
||||||
|
| ring-opacity-* | ring-black/* |
|
||||||
|
| placeholder-opacity-* | placeholder-black/* |
|
||||||
|
| flex-shrink-* | shrink-* |
|
||||||
|
| flex-grow-* | grow-* |
|
||||||
|
| overflow-ellipsis | text-ellipsis |
|
||||||
|
| decoration-slice | box-decoration-slice |
|
||||||
|
| decoration-clone | box-decoration-clone |
|
||||||
|
|
||||||
|
=== laravel/fortify rules ===
|
||||||
|
|
||||||
|
## Laravel Fortify
|
||||||
|
|
||||||
|
Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
|
||||||
|
|
||||||
|
**Before implementing any authentication features, use the `search-docs` tool to get the latest docs for that specific feature.**
|
||||||
|
|
||||||
|
### Configuration & Setup
|
||||||
|
- Check `config/fortify.php` to see what's enabled. Use `search-docs` for detailed information on specific features.
|
||||||
|
- Enable features by adding them to the `'features' => []` array: `Features::registration()`, `Features::resetPasswords()`, etc.
|
||||||
|
- To see the all Fortify registered routes, use the `list-routes` tool with the `only_vendor: true` and `action: "Fortify"` parameters.
|
||||||
|
- Fortify includes view routes by default (login, register). Set `'views' => false` in the configuration file to disable them if you're handling views yourself.
|
||||||
|
|
||||||
|
### Customization
|
||||||
|
- Views can be customized in `FortifyServiceProvider`'s `boot()` method using `Fortify::loginView()`, `Fortify::registerView()`, etc.
|
||||||
|
- Customize authentication logic with `Fortify::authenticateUsing()` for custom user retrieval / validation.
|
||||||
|
- Actions in `app/Actions/Fortify/` handle business logic (user creation, password reset, etc.). They're fully customizable, so you can modify them to change feature behavior.
|
||||||
|
|
||||||
|
## Available Features
|
||||||
|
- `Features::registration()` for user registration.
|
||||||
|
- `Features::emailVerification()` to verify new user emails.
|
||||||
|
- `Features::twoFactorAuthentication()` for 2FA with QR codes and recovery codes.
|
||||||
|
- Add options: `['confirmPassword' => true, 'confirm' => true]` to require password confirmation and OTP confirmation before enabling 2FA.
|
||||||
|
- `Features::updateProfileInformation()` to let users update their profile.
|
||||||
|
- `Features::updatePasswords()` to let users change their passwords.
|
||||||
|
- `Features::resetPasswords()` for password reset via email.
|
||||||
|
</laravel-boost-guidelines>
|
||||||
539
GEMINI.md
Normal file
539
GEMINI.md
Normal file
|
|
@ -0,0 +1,539 @@
|
||||||
|
<laravel-boost-guidelines>
|
||||||
|
=== foundation rules ===
|
||||||
|
|
||||||
|
# Laravel Boost Guidelines
|
||||||
|
|
||||||
|
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
||||||
|
|
||||||
|
## Foundational Context
|
||||||
|
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||||
|
|
||||||
|
- php - 8.4.16
|
||||||
|
- inertiajs/inertia-laravel (INERTIA) - v2
|
||||||
|
- laravel/cashier (CASHIER) - v16
|
||||||
|
- laravel/fortify (FORTIFY) - v1
|
||||||
|
- laravel/framework (LARAVEL) - v12
|
||||||
|
- laravel/horizon (HORIZON) - v5
|
||||||
|
- laravel/prompts (PROMPTS) - v0
|
||||||
|
- laravel/socialite (SOCIALITE) - v5
|
||||||
|
- laravel/wayfinder (WAYFINDER) - v0
|
||||||
|
- laravel/mcp (MCP) - v0
|
||||||
|
- laravel/pint (PINT) - v1
|
||||||
|
- laravel/sail (SAIL) - v1
|
||||||
|
- pestphp/pest (PEST) - v4
|
||||||
|
- phpunit/phpunit (PHPUNIT) - v12
|
||||||
|
- @inertiajs/vue3 (INERTIA) - v2
|
||||||
|
- tailwindcss (TAILWINDCSS) - v4
|
||||||
|
- vue (VUE) - v3
|
||||||
|
- @laravel/vite-plugin-wayfinder (WAYFINDER) - v0
|
||||||
|
- eslint (ESLINT) - v9
|
||||||
|
- prettier (PRETTIER) - v3
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||||
|
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||||
|
- Check for existing components to reuse before writing a new one.
|
||||||
|
|
||||||
|
## Verification Scripts
|
||||||
|
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
||||||
|
|
||||||
|
## Application Structure & Architecture
|
||||||
|
- Stick to existing directory structure; don't create new base folders without approval.
|
||||||
|
- Do not change the application's dependencies without approval.
|
||||||
|
|
||||||
|
## Frontend Bundling
|
||||||
|
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||||
|
|
||||||
|
## Replies
|
||||||
|
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||||
|
|
||||||
|
## Documentation Files
|
||||||
|
- You must only create documentation files if explicitly requested by the user.
|
||||||
|
|
||||||
|
=== boost rules ===
|
||||||
|
|
||||||
|
## Laravel Boost
|
||||||
|
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||||
|
|
||||||
|
## Artisan
|
||||||
|
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||||
|
|
||||||
|
## Tinker / Debugging
|
||||||
|
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||||
|
- Use the `database-query` tool when you only need to read from the database.
|
||||||
|
|
||||||
|
## Reading Browser Logs With the `browser-logs` Tool
|
||||||
|
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||||
|
- Only recent browser logs will be useful - ignore old logs.
|
||||||
|
|
||||||
|
## Searching Documentation (Critically Important)
|
||||||
|
- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||||
|
- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||||
|
- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
|
||||||
|
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||||
|
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
||||||
|
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||||
|
|
||||||
|
### Available Search Syntax
|
||||||
|
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
||||||
|
|
||||||
|
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||||
|
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||||
|
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||||
|
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||||
|
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||||
|
|
||||||
|
=== php rules ===
|
||||||
|
|
||||||
|
## PHP
|
||||||
|
|
||||||
|
- Always use curly braces for control structures, even if it has one line.
|
||||||
|
|
||||||
|
### Constructors
|
||||||
|
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||||
|
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||||
|
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||||
|
|
||||||
|
### Type Declarations
|
||||||
|
- Always use explicit return type declarations for methods and functions.
|
||||||
|
- Use appropriate PHP type hints for method parameters.
|
||||||
|
|
||||||
|
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||||
|
protected function isAccessible(User $user, ?string $path = null): bool
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
|
||||||
|
|
||||||
|
## PHPDoc Blocks
|
||||||
|
- Add useful array shape type definitions for arrays when appropriate.
|
||||||
|
|
||||||
|
## Enums
|
||||||
|
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||||
|
|
||||||
|
=== herd rules ===
|
||||||
|
|
||||||
|
## Laravel Herd
|
||||||
|
|
||||||
|
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs.
|
||||||
|
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
|
||||||
|
|
||||||
|
=== tests rules ===
|
||||||
|
|
||||||
|
## Test Enforcement
|
||||||
|
|
||||||
|
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||||
|
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||||
|
|
||||||
|
=== inertia-laravel/core rules ===
|
||||||
|
|
||||||
|
## Inertia
|
||||||
|
|
||||||
|
- Inertia.js components should be placed in the `resources/js/Pages` directory unless specified differently in the JS bundler (`vite.config.js`).
|
||||||
|
- Use `Inertia::render()` for server-side routing instead of traditional Blade views.
|
||||||
|
- Use the `search-docs` tool for accurate guidance on all things Inertia.
|
||||||
|
|
||||||
|
<code-snippet name="Inertia Render Example" lang="php">
|
||||||
|
// routes/web.php example
|
||||||
|
Route::get('/users', function () {
|
||||||
|
return Inertia::render('Users/Index', [
|
||||||
|
'users' => User::all()
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
=== inertia-laravel/v2 rules ===
|
||||||
|
|
||||||
|
## Inertia v2
|
||||||
|
|
||||||
|
- Make use of all Inertia features from v1 and v2. Check the documentation before making any changes to ensure we are taking the correct approach.
|
||||||
|
|
||||||
|
### Inertia v2 New Features
|
||||||
|
- Deferred props.
|
||||||
|
- Infinite scrolling using merging props and `WhenVisible`.
|
||||||
|
- Lazy loading data on scroll.
|
||||||
|
- Polling.
|
||||||
|
- Prefetching.
|
||||||
|
|
||||||
|
### Deferred Props & Empty States
|
||||||
|
- When using deferred props on the frontend, you should add a nice empty state with pulsing/animated skeleton.
|
||||||
|
|
||||||
|
### Inertia Form General Guidance
|
||||||
|
- The recommended way to build forms when using Inertia is with the `<Form>` component - a useful example is below. Use the `search-docs` tool with a query of `form component` for guidance.
|
||||||
|
- Forms can also be built using the `useForm` helper for more programmatic control, or to follow existing conventions. Use the `search-docs` tool with a query of `useForm helper` for guidance.
|
||||||
|
- `resetOnError`, `resetOnSuccess`, and `setDefaultsOnSuccess` are available on the `<Form>` component. Use the `search-docs` tool with a query of `form component resetting` for guidance.
|
||||||
|
|
||||||
|
=== laravel/core rules ===
|
||||||
|
|
||||||
|
## Do Things the Laravel Way
|
||||||
|
|
||||||
|
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||||
|
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||||
|
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||||
|
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||||
|
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||||
|
- Generate code that prevents N+1 query problems by using eager loading.
|
||||||
|
- Use Laravel's query builder for very complex database operations.
|
||||||
|
|
||||||
|
### Model Creation
|
||||||
|
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||||
|
|
||||||
|
### APIs & Eloquent Resources
|
||||||
|
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||||
|
|
||||||
|
### Controllers & Validation
|
||||||
|
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||||
|
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||||
|
|
||||||
|
### Queues
|
||||||
|
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||||
|
|
||||||
|
### URL Generation
|
||||||
|
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||||
|
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||||
|
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||||
|
|
||||||
|
### Vite Error
|
||||||
|
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||||
|
|
||||||
|
=== laravel/v12 rules ===
|
||||||
|
|
||||||
|
## Laravel 12
|
||||||
|
|
||||||
|
- Use the `search-docs` tool to get version-specific documentation.
|
||||||
|
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||||
|
|
||||||
|
### Laravel 12 Structure
|
||||||
|
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
||||||
|
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
||||||
|
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||||
|
- `bootstrap/providers.php` contains application specific service providers.
|
||||||
|
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||||
|
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||||
|
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||||
|
|
||||||
|
### Models
|
||||||
|
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||||
|
|
||||||
|
=== wayfinder/core rules ===
|
||||||
|
|
||||||
|
## Laravel Wayfinder
|
||||||
|
|
||||||
|
Wayfinder generates TypeScript functions and types for Laravel controllers and routes which you can import into your client-side code. It provides type safety and automatic synchronization between backend routes and frontend code.
|
||||||
|
|
||||||
|
### Development Guidelines
|
||||||
|
- Always use the `search-docs` tool to check Wayfinder correct usage before implementing any features.
|
||||||
|
- Always prefer named imports for tree-shaking (e.g., `import { show } from '@/actions/...'`).
|
||||||
|
- Avoid default controller imports (prevents tree-shaking).
|
||||||
|
- Run `php artisan wayfinder:generate` after route changes if Vite plugin isn't installed.
|
||||||
|
|
||||||
|
### Feature Overview
|
||||||
|
- Form Support: Use `.form()` with `--with-form` flag for HTML form attributes — `<form {...store.form()}>` → `action="/posts" method="post"`.
|
||||||
|
- HTTP Methods: Call `.get()`, `.post()`, `.patch()`, `.put()`, `.delete()` for specific methods — `show.head(1)` → `{ url: "/posts/1", method: "head" }`.
|
||||||
|
- Invokable Controllers: Import and invoke directly as functions. For example, `import StorePost from '@/actions/.../StorePostController'; StorePost()`.
|
||||||
|
- Named Routes: Import from `@/routes/` for non-controller routes. For example, `import { show } from '@/routes/post'; show(1)` for route name `post.show`.
|
||||||
|
- Parameter Binding: Detects route keys (e.g., `{post:slug}`) and accepts matching object properties — `show("my-post")` or `show({ slug: "my-post" })`.
|
||||||
|
- Query Merging: Use `mergeQuery` to merge with `window.location.search`, set values to `null` to remove — `show(1, { mergeQuery: { page: 2, sort: null } })`.
|
||||||
|
- Query Parameters: Pass `{ query: {...} }` in options to append params — `show(1, { query: { page: 1 } })` → `"/posts/1?page=1"`.
|
||||||
|
- Route Objects: Functions return `{ url, method }` shaped objects — `show(1)` → `{ url: "/posts/1", method: "get" }`.
|
||||||
|
- URL Extraction: Use `.url()` to get URL string — `show.url(1)` → `"/posts/1"`.
|
||||||
|
|
||||||
|
### Example Usage
|
||||||
|
|
||||||
|
<code-snippet name="Wayfinder Basic Usage" lang="typescript">
|
||||||
|
// Import controller methods (tree-shakable)...
|
||||||
|
import { show, store, update } from '@/actions/App/Http/Controllers/PostController'
|
||||||
|
|
||||||
|
// Get route object with URL and method...
|
||||||
|
show(1) // { url: "/posts/1", method: "get" }
|
||||||
|
|
||||||
|
// Get just the URL...
|
||||||
|
show.url(1) // "/posts/1"
|
||||||
|
|
||||||
|
// Use specific HTTP methods...
|
||||||
|
show.get(1) // { url: "/posts/1", method: "get" }
|
||||||
|
show.head(1) // { url: "/posts/1", method: "head" }
|
||||||
|
|
||||||
|
// Import named routes...
|
||||||
|
import { show as postShow } from '@/routes/post' // For route name 'post.show'
|
||||||
|
postShow(1) // { url: "/posts/1", method: "get" }
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
### Wayfinder + Inertia
|
||||||
|
If your application uses the `<Form>` component from Inertia, you can use Wayfinder to generate form action and method automatically.
|
||||||
|
<code-snippet name="Wayfinder Form Component (Vue)" lang="vue">
|
||||||
|
|
||||||
|
<Form v-bind="store.form()"><input name="title" /></Form>
|
||||||
|
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
=== pint/core rules ===
|
||||||
|
|
||||||
|
## Laravel Pint Code Formatter
|
||||||
|
|
||||||
|
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||||
|
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
|
||||||
|
|
||||||
|
=== pest/core rules ===
|
||||||
|
|
||||||
|
## Pest
|
||||||
|
### Testing
|
||||||
|
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||||
|
|
||||||
|
### Pest Tests
|
||||||
|
- All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
|
||||||
|
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
||||||
|
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||||
|
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||||
|
- Pest tests look and behave like this:
|
||||||
|
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||||
|
it('is true', function () {
|
||||||
|
expect(true)->toBeTrue();
|
||||||
|
});
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
||||||
|
- To run all tests: `php artisan test --compact`.
|
||||||
|
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||||
|
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||||
|
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
||||||
|
|
||||||
|
### Pest Assertions
|
||||||
|
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
|
||||||
|
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
||||||
|
it('returns all', function () {
|
||||||
|
$response = $this->postJson('/api/docs', []);
|
||||||
|
|
||||||
|
$response->assertSuccessful();
|
||||||
|
});
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
### Mocking
|
||||||
|
- Mocking can be very helpful when appropriate.
|
||||||
|
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
|
||||||
|
- You can also create partial mocks using the same import or self method.
|
||||||
|
|
||||||
|
### Datasets
|
||||||
|
- Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules.
|
||||||
|
|
||||||
|
<code-snippet name="Pest Dataset Example" lang="php">
|
||||||
|
it('has emails', function (string $email) {
|
||||||
|
expect($email)->not->toBeEmpty();
|
||||||
|
})->with([
|
||||||
|
'james' => 'james@laravel.com',
|
||||||
|
'taylor' => 'taylor@laravel.com',
|
||||||
|
]);
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
=== pest/v4 rules ===
|
||||||
|
|
||||||
|
## Pest 4
|
||||||
|
|
||||||
|
- Pest 4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
|
||||||
|
- Browser testing is incredibly powerful and useful for this project.
|
||||||
|
- Browser tests should live in `tests/Browser/`.
|
||||||
|
- Use the `search-docs` tool for detailed guidance on utilizing these features.
|
||||||
|
|
||||||
|
### Browser Testing
|
||||||
|
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest 4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
|
||||||
|
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
|
||||||
|
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
|
||||||
|
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
|
||||||
|
- Switch color schemes (light/dark mode) when appropriate.
|
||||||
|
- Take screenshots or pause tests for debugging when appropriate.
|
||||||
|
|
||||||
|
### Example Tests
|
||||||
|
|
||||||
|
<code-snippet name="Pest Browser Test Example" lang="php">
|
||||||
|
it('may reset the password', function () {
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
|
||||||
|
$page = visit('/sign-in'); // Visit on a real browser...
|
||||||
|
|
||||||
|
$page->assertSee('Sign In')
|
||||||
|
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
|
||||||
|
->click('Forgot Password?')
|
||||||
|
->fill('email', 'nuno@laravel.com')
|
||||||
|
->click('Send Reset Link')
|
||||||
|
->assertSee('We have emailed your password reset link!')
|
||||||
|
|
||||||
|
Notification::assertSent(ResetPassword::class);
|
||||||
|
});
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
<code-snippet name="Pest Smoke Testing Example" lang="php">
|
||||||
|
$pages = visit(['/', '/about', '/contact']);
|
||||||
|
|
||||||
|
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
=== inertia-vue/core rules ===
|
||||||
|
|
||||||
|
## Inertia + Vue
|
||||||
|
|
||||||
|
- Vue components must have a single root element.
|
||||||
|
- Use `router.visit()` or `<Link>` for navigation instead of traditional links.
|
||||||
|
|
||||||
|
<code-snippet name="Inertia Client Navigation" lang="vue">
|
||||||
|
|
||||||
|
import { Link } from '@inertiajs/vue3'
|
||||||
|
<Link href="/">Home</Link>
|
||||||
|
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
=== inertia-vue/v2/forms rules ===
|
||||||
|
|
||||||
|
## Inertia v2 + Vue Forms
|
||||||
|
|
||||||
|
<code-snippet name="`<Form>` Component Example" lang="vue">
|
||||||
|
|
||||||
|
<Form
|
||||||
|
action="/users"
|
||||||
|
method="post"
|
||||||
|
#default="{
|
||||||
|
errors,
|
||||||
|
hasErrors,
|
||||||
|
processing,
|
||||||
|
progress,
|
||||||
|
wasSuccessful,
|
||||||
|
recentlySuccessful,
|
||||||
|
setError,
|
||||||
|
clearErrors,
|
||||||
|
resetAndClearErrors,
|
||||||
|
defaults,
|
||||||
|
isDirty,
|
||||||
|
reset,
|
||||||
|
submit,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<input type="text" name="name" />
|
||||||
|
|
||||||
|
<div v-if="errors.name">
|
||||||
|
{{ errors.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" :disabled="processing">
|
||||||
|
{{ processing ? 'Creating...' : 'Create User' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="wasSuccessful">User created successfully!</div>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
=== tailwindcss/core rules ===
|
||||||
|
|
||||||
|
## Tailwind CSS
|
||||||
|
|
||||||
|
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
|
||||||
|
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
|
||||||
|
- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
|
||||||
|
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
||||||
|
|
||||||
|
### Spacing
|
||||||
|
- When listing items, use gap utilities for spacing; don't use margins.
|
||||||
|
|
||||||
|
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<div>Superior</div>
|
||||||
|
<div>Michigan</div>
|
||||||
|
<div>Erie</div>
|
||||||
|
</div>
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
### Dark Mode
|
||||||
|
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
||||||
|
|
||||||
|
=== tailwindcss/v4 rules ===
|
||||||
|
|
||||||
|
## Tailwind CSS 4
|
||||||
|
|
||||||
|
- Always use Tailwind CSS v4; do not use the deprecated utilities.
|
||||||
|
- `corePlugins` is not supported in Tailwind v4.
|
||||||
|
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
|
||||||
|
|
||||||
|
<code-snippet name="Extending Theme in CSS" lang="css">
|
||||||
|
@theme {
|
||||||
|
--color-brand: oklch(0.72 0.11 178);
|
||||||
|
}
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
|
||||||
|
|
||||||
|
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
|
||||||
|
- @tailwind base;
|
||||||
|
- @tailwind components;
|
||||||
|
- @tailwind utilities;
|
||||||
|
+ @import "tailwindcss";
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
### Replaced Utilities
|
||||||
|
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
|
||||||
|
- Opacity values are still numeric.
|
||||||
|
|
||||||
|
| Deprecated | Replacement |
|
||||||
|
|------------+--------------|
|
||||||
|
| bg-opacity-* | bg-black/* |
|
||||||
|
| text-opacity-* | text-black/* |
|
||||||
|
| border-opacity-* | border-black/* |
|
||||||
|
| divide-opacity-* | divide-black/* |
|
||||||
|
| ring-opacity-* | ring-black/* |
|
||||||
|
| placeholder-opacity-* | placeholder-black/* |
|
||||||
|
| flex-shrink-* | shrink-* |
|
||||||
|
| flex-grow-* | grow-* |
|
||||||
|
| overflow-ellipsis | text-ellipsis |
|
||||||
|
| decoration-slice | box-decoration-slice |
|
||||||
|
| decoration-clone | box-decoration-clone |
|
||||||
|
|
||||||
|
=== laravel/fortify rules ===
|
||||||
|
|
||||||
|
## Laravel Fortify
|
||||||
|
|
||||||
|
Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
|
||||||
|
|
||||||
|
**Before implementing any authentication features, use the `search-docs` tool to get the latest docs for that specific feature.**
|
||||||
|
|
||||||
|
### Configuration & Setup
|
||||||
|
- Check `config/fortify.php` to see what's enabled. Use `search-docs` for detailed information on specific features.
|
||||||
|
- Enable features by adding them to the `'features' => []` array: `Features::registration()`, `Features::resetPasswords()`, etc.
|
||||||
|
- To see the all Fortify registered routes, use the `list-routes` tool with the `only_vendor: true` and `action: "Fortify"` parameters.
|
||||||
|
- Fortify includes view routes by default (login, register). Set `'views' => false` in the configuration file to disable them if you're handling views yourself.
|
||||||
|
|
||||||
|
### Customization
|
||||||
|
- Views can be customized in `FortifyServiceProvider`'s `boot()` method using `Fortify::loginView()`, `Fortify::registerView()`, etc.
|
||||||
|
- Customize authentication logic with `Fortify::authenticateUsing()` for custom user retrieval / validation.
|
||||||
|
- Actions in `app/Actions/Fortify/` handle business logic (user creation, password reset, etc.). They're fully customizable, so you can modify them to change feature behavior.
|
||||||
|
|
||||||
|
## Available Features
|
||||||
|
- `Features::registration()` for user registration.
|
||||||
|
- `Features::emailVerification()` to verify new user emails.
|
||||||
|
- `Features::twoFactorAuthentication()` for 2FA with QR codes and recovery codes.
|
||||||
|
- Add options: `['confirmPassword' => true, 'confirm' => true]` to require password confirmation and OTP confirmation before enabling 2FA.
|
||||||
|
- `Features::updateProfileInformation()` to let users update their profile.
|
||||||
|
- `Features::updatePasswords()` to let users change their passwords.
|
||||||
|
- `Features::resetPasswords()` for password reset via email.
|
||||||
|
</laravel-boost-guidelines>
|
||||||
46
app/Actions/Fortify/CreateNewUser.php
Normal file
46
app/Actions/Fortify/CreateNewUser.php
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Concerns\ProfileValidationRules;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||||
|
|
||||||
|
class CreateNewUser implements CreatesNewUsers
|
||||||
|
{
|
||||||
|
use ProfileValidationRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and create a newly registered user.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*/
|
||||||
|
public function create(array $input): User
|
||||||
|
{
|
||||||
|
Validator::make($input, [
|
||||||
|
...$this->profileRules(),
|
||||||
|
'password' => ['required', 'string', Password::default()],
|
||||||
|
])->validate();
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($input) {
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $input['name'],
|
||||||
|
'email' => $input['email'],
|
||||||
|
'password' => $input['password'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create default workspace for new user
|
||||||
|
$workspace = $user->workspaces()->create([
|
||||||
|
'name' => 'Meu Workspace',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add user as owner member
|
||||||
|
$workspace->members()->attach($user->id, ['role' => 'owner']);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Actions/Fortify/PasswordValidationRules.php
Normal file
18
app/Actions/Fortify/PasswordValidationRules.php
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
trait PasswordValidationRules
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules used to validate passwords.
|
||||||
|
*
|
||||||
|
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
protected function passwordRules(): array
|
||||||
|
{
|
||||||
|
return ['required', 'string', Password::default(), 'confirmed'];
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Actions/Fortify/ResetUserPassword.php
Normal file
29
app/Actions/Fortify/ResetUserPassword.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Concerns\PasswordValidationRules;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||||
|
|
||||||
|
class ResetUserPassword implements ResetsUserPasswords
|
||||||
|
{
|
||||||
|
use PasswordValidationRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and reset the user's forgotten password.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*/
|
||||||
|
public function reset(User $user, array $input): void
|
||||||
|
{
|
||||||
|
Validator::make($input, [
|
||||||
|
'password' => $this->passwordRules(),
|
||||||
|
])->validate();
|
||||||
|
|
||||||
|
$user->forceFill([
|
||||||
|
'password' => $input['password'],
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Concerns/PasswordValidationRules.php
Normal file
28
app/Concerns/PasswordValidationRules.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
trait PasswordValidationRules
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules used to validate passwords.
|
||||||
|
*
|
||||||
|
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
protected function passwordRules(): array
|
||||||
|
{
|
||||||
|
return ['required', 'string', Password::default(), 'confirmed'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules used to validate the current password.
|
||||||
|
*
|
||||||
|
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
protected function currentPasswordRules(): array
|
||||||
|
{
|
||||||
|
return ['required', 'string', 'current_password'];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Concerns/ProfileValidationRules.php
Normal file
50
app/Concerns/ProfileValidationRules.php
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Concerns;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
trait ProfileValidationRules
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules used to validate user profiles.
|
||||||
|
*
|
||||||
|
* @return array<string, array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>>
|
||||||
|
*/
|
||||||
|
protected function profileRules(string|int|null $userId = null): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $this->nameRules(),
|
||||||
|
'email' => $this->emailRules($userId),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules used to validate user names.
|
||||||
|
*
|
||||||
|
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
protected function nameRules(): array
|
||||||
|
{
|
||||||
|
return ['required', 'string', 'max:255'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules used to validate user emails.
|
||||||
|
*
|
||||||
|
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
protected function emailRules(string|int|null $userId = null): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
$userId === null
|
||||||
|
? Rule::unique(User::class)
|
||||||
|
: Rule::unique(User::class)->ignore($userId),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Enums/InviteStatus.php
Normal file
31
app/Enums/InviteStatus.php
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum InviteStatus: string
|
||||||
|
{
|
||||||
|
case Pending = 'pending';
|
||||||
|
case Accepted = 'accepted';
|
||||||
|
case Expired = 'expired';
|
||||||
|
case Cancelled = 'cancelled';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Pending => 'Pendente',
|
||||||
|
self::Accepted => 'Aceito',
|
||||||
|
self::Expired => 'Expirado',
|
||||||
|
self::Cancelled => 'Cancelado',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function color(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Pending => 'yellow',
|
||||||
|
self::Accepted => 'green',
|
||||||
|
self::Expired => 'gray',
|
||||||
|
self::Cancelled => 'red',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Enums/MediaType.php
Normal file
37
app/Enums/MediaType.php
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum MediaType: string
|
||||||
|
{
|
||||||
|
case Image = 'image';
|
||||||
|
case Video = 'video';
|
||||||
|
case Document = 'document';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Image => 'Imagem',
|
||||||
|
self::Video => 'Vídeo',
|
||||||
|
self::Document => 'Documento',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function allowedMimeTypes(): array
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Image => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
||||||
|
self::Video => ['video/mp4', 'video/quicktime', 'video/webm'],
|
||||||
|
self::Document => ['application/pdf'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maxSizeInMb(): int
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Image => 10,
|
||||||
|
self::Video => 500,
|
||||||
|
self::Document => 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Enums/PostStatus.php
Normal file
34
app/Enums/PostStatus.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum PostStatus: string
|
||||||
|
{
|
||||||
|
case Draft = 'draft';
|
||||||
|
case Scheduled = 'scheduled';
|
||||||
|
case Publishing = 'publishing';
|
||||||
|
case Published = 'published';
|
||||||
|
case Failed = 'failed';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Draft => 'Rascunho',
|
||||||
|
self::Scheduled => 'Agendado',
|
||||||
|
self::Publishing => 'Publicando',
|
||||||
|
self::Published => 'Publicado',
|
||||||
|
self::Failed => 'Falhou',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function color(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Draft => 'gray',
|
||||||
|
self::Scheduled => 'blue',
|
||||||
|
self::Publishing => 'yellow',
|
||||||
|
self::Published => 'green',
|
||||||
|
self::Failed => 'red',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/Enums/SocialPlatform.php
Normal file
66
app/Enums/SocialPlatform.php
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum SocialPlatform: string
|
||||||
|
{
|
||||||
|
case LinkedIn = 'linkedin';
|
||||||
|
case LinkedInPage = 'linkedin-page';
|
||||||
|
case X = 'x';
|
||||||
|
case TikTok = 'tiktok';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::LinkedIn => 'LinkedIn',
|
||||||
|
self::LinkedInPage => 'LinkedIn Page',
|
||||||
|
self::X => 'X',
|
||||||
|
self::TikTok => 'TikTok',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function color(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::LinkedIn, self::LinkedInPage => '#0A66C2',
|
||||||
|
self::X => '#000000',
|
||||||
|
self::TikTok => '#000000',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function allowedMediaTypes(): array
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::LinkedIn, self::LinkedInPage => [MediaType::Image, MediaType::Video, MediaType::Document],
|
||||||
|
self::X => [MediaType::Image, MediaType::Video],
|
||||||
|
self::TikTok => [MediaType::Video],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maxImages(): int
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::LinkedIn, self::LinkedInPage => 1,
|
||||||
|
self::X => 4,
|
||||||
|
self::TikTok => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maxContentLength(): int
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::LinkedIn, self::LinkedInPage => 3000,
|
||||||
|
self::X => 280,
|
||||||
|
self::TikTok => 2200,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supportsTextOnly(): bool
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::LinkedIn, self::LinkedInPage => true,
|
||||||
|
self::X => true,
|
||||||
|
self::TikTok => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Enums/WorkspaceRole.php
Normal file
17
app/Enums/WorkspaceRole.php
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum WorkspaceRole: string
|
||||||
|
{
|
||||||
|
case Owner = 'owner';
|
||||||
|
case Member = 'member';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Owner => 'Proprietário',
|
||||||
|
self::Member => 'Membro',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
125
app/Http/Controllers/Auth/LinkedInController.php
Normal file
125
app/Http/Controllers/Auth/LinkedInController.php
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Enums\SocialPlatform;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class LinkedInController extends SocialController
|
||||||
|
{
|
||||||
|
protected string $driver = 'linkedin';
|
||||||
|
|
||||||
|
protected SocialPlatform $platform = SocialPlatform::LinkedIn;
|
||||||
|
|
||||||
|
protected array $scopes = [
|
||||||
|
'openid',
|
||||||
|
'profile',
|
||||||
|
'email',
|
||||||
|
'r_basicprofile',
|
||||||
|
'w_member_social',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function connect(Request $request, Workspace $workspace): Response
|
||||||
|
{
|
||||||
|
$this->authorize('update', $workspace);
|
||||||
|
|
||||||
|
if ($workspace->hasConnectedPlatform($this->platform->value)) {
|
||||||
|
return back()->with('error', 'Esta plataforma já está conectada.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToProvider($workspace, $this->driver, $this->scopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function callback(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$workspaceId = session('social_connect_workspace');
|
||||||
|
|
||||||
|
if (! $workspaceId) {
|
||||||
|
return redirect()->route('workspaces.index')
|
||||||
|
->with('error', 'Sessão expirada. Tente novamente.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::find($workspaceId);
|
||||||
|
|
||||||
|
if (! $workspace || ! $request->user()->can('update', $workspace)) {
|
||||||
|
return redirect()->route('workspaces.index')
|
||||||
|
->with('error', 'Workspace não encontrado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($workspace->hasConnectedPlatform($this->platform->value)) {
|
||||||
|
return redirect()->route('workspaces.accounts', $workspace)
|
||||||
|
->with('error', 'Esta plataforma já está conectada.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$socialUser = Socialite::driver($this->driver)->user();
|
||||||
|
|
||||||
|
// Fetch vanityName from LinkedIn API (not available via OpenID)
|
||||||
|
$username = $this->fetchVanityName($socialUser->token);
|
||||||
|
|
||||||
|
Log::info('LinkedIn OAuth User Data', [
|
||||||
|
'nickname' => $socialUser->getNickname(),
|
||||||
|
'username' => $username,
|
||||||
|
'user' => $socialUser->user ?? [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$avatarPath = uploadFromUrl($socialUser->getAvatar());
|
||||||
|
|
||||||
|
$workspace->socialAccounts()->create([
|
||||||
|
'platform' => $this->platform->value,
|
||||||
|
'platform_user_id' => $socialUser->getId(),
|
||||||
|
'username' => $username,
|
||||||
|
'display_name' => $socialUser->getName(),
|
||||||
|
'avatar_url' => $avatarPath,
|
||||||
|
'access_token' => $socialUser->token,
|
||||||
|
'refresh_token' => $socialUser->refreshToken,
|
||||||
|
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
|
||||||
|
'scopes' => $socialUser->approvedScopes ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->forget('social_connect_workspace');
|
||||||
|
|
||||||
|
return redirect()->route('workspaces.accounts', $workspace)
|
||||||
|
->with('success', 'Conta conectada com sucesso!');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('LinkedIn OAuth Error', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('workspaces.accounts', $workspace)
|
||||||
|
->with('error', 'Erro ao conectar conta. Tente novamente.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchVanityName(string $accessToken): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = Http::withToken($accessToken)
|
||||||
|
->withHeaders(['X-RestLi-Protocol-Version' => '2.0.0'])
|
||||||
|
->get('https://api.linkedin.com/v2/me', [
|
||||||
|
'projection' => '(id,vanityName,localizedFirstName,localizedLastName)',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info('LinkedIn /me API response', [
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->json(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return $response->json('vanityName');
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::warning('Failed to fetch LinkedIn vanityName', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
229
app/Http/Controllers/Auth/LinkedInPageController.php
Normal file
229
app/Http/Controllers/Auth/LinkedInPageController.php
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Enums\SocialPlatform;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
|
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||||
|
|
||||||
|
class LinkedInPageController extends SocialController
|
||||||
|
{
|
||||||
|
protected string $driver = 'linkedin-openid';
|
||||||
|
|
||||||
|
protected SocialPlatform $platform = SocialPlatform::LinkedInPage;
|
||||||
|
|
||||||
|
protected array $scopes = [
|
||||||
|
'openid',
|
||||||
|
'profile',
|
||||||
|
'email',
|
||||||
|
'w_organization_social',
|
||||||
|
'r_organization_social',
|
||||||
|
'rw_organization_admin',
|
||||||
|
'w_member_social',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function connect(Request $request, Workspace $workspace): SymfonyResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', $workspace);
|
||||||
|
|
||||||
|
if ($workspace->hasConnectedPlatform($this->platform->value)) {
|
||||||
|
return back()->with('error', 'Esta plataforma já está conectada.');
|
||||||
|
}
|
||||||
|
|
||||||
|
session(['social_connect_workspace' => $workspace->id]);
|
||||||
|
|
||||||
|
return Inertia::location(
|
||||||
|
Socialite::driver($this->driver)
|
||||||
|
->scopes($this->scopes)
|
||||||
|
->with([
|
||||||
|
'redirect_uri' => config('services.linkedin-openid.redirect_page'),
|
||||||
|
])
|
||||||
|
->redirect()
|
||||||
|
->getTargetUrl()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function callback(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$workspaceId = session('social_connect_workspace');
|
||||||
|
|
||||||
|
if (! $workspaceId) {
|
||||||
|
return redirect()->route('workspaces.index')
|
||||||
|
->with('error', 'Sessão expirada. Tente novamente.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::find($workspaceId);
|
||||||
|
|
||||||
|
if (! $workspace || ! $request->user()->can('update', $workspace)) {
|
||||||
|
return redirect()->route('workspaces.index')
|
||||||
|
->with('error', 'Workspace não encontrado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$socialUser = Socialite::driver($this->driver)
|
||||||
|
->scopes($this->scopes)
|
||||||
|
->with([
|
||||||
|
'redirect_uri' => config('services.linkedin-openid.redirect_page'),
|
||||||
|
])
|
||||||
|
->user();
|
||||||
|
|
||||||
|
// Fetch organizations the user is admin of
|
||||||
|
$organizations = $this->fetchOrganizations($socialUser->token);
|
||||||
|
|
||||||
|
if (empty($organizations)) {
|
||||||
|
session()->forget('social_connect_workspace');
|
||||||
|
|
||||||
|
return redirect()->route('workspaces.accounts', $workspace)
|
||||||
|
->with('error', 'Você não é administrador de nenhuma página do LinkedIn.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store data in session and redirect to selection page
|
||||||
|
session([
|
||||||
|
'linkedin_page_pending' => [
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'user_id' => $socialUser->getId(),
|
||||||
|
'name' => $socialUser->getName(),
|
||||||
|
'avatar' => $socialUser->getAvatar(),
|
||||||
|
'token' => $socialUser->token,
|
||||||
|
'refresh_token' => $socialUser->refreshToken,
|
||||||
|
'expires_in' => $socialUser->expiresIn,
|
||||||
|
'organizations' => $organizations,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('social.linkedin-page.select-page');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('LinkedIn Page OAuth Error', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('workspaces.accounts', $workspace)
|
||||||
|
->with('error', 'Erro ao conectar conta. Tente novamente.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectPage(Request $request): Response|RedirectResponse
|
||||||
|
{
|
||||||
|
$pendingData = session('linkedin_page_pending');
|
||||||
|
|
||||||
|
if (! $pendingData) {
|
||||||
|
return redirect()->route('workspaces.index')
|
||||||
|
->with('error', 'Sessão expirada. Tente novamente.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::find($pendingData['workspace_id']);
|
||||||
|
|
||||||
|
if (! $workspace || ! $request->user()->can('update', $workspace)) {
|
||||||
|
return redirect()->route('workspaces.index')
|
||||||
|
->with('error', 'Workspace não encontrado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('accounts/LinkedInPageSelect', [
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'organizations' => $pendingData['organizations'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function select(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'organization_id' => 'required',
|
||||||
|
'organization_name' => 'required|string',
|
||||||
|
'organization_vanity_name' => 'nullable|string',
|
||||||
|
'organization_logo' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pendingData = session('linkedin_page_pending');
|
||||||
|
|
||||||
|
if (! $pendingData) {
|
||||||
|
return redirect()->route('workspaces.index')
|
||||||
|
->with('error', 'Sessão expirada. Tente novamente.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::find($pendingData['workspace_id']);
|
||||||
|
|
||||||
|
if (! $workspace || ! $request->user()->can('update', $workspace)) {
|
||||||
|
return redirect()->route('workspaces.index')
|
||||||
|
->with('error', 'Workspace não encontrado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$avatarPath = uploadFromUrl($request->organization_logo);
|
||||||
|
|
||||||
|
$workspace->socialAccounts()->create([
|
||||||
|
'platform' => $this->platform->value,
|
||||||
|
'platform_user_id' => $request->organization_id,
|
||||||
|
'username' => $request->organization_vanity_name,
|
||||||
|
'display_name' => $request->organization_name,
|
||||||
|
'avatar_url' => $avatarPath,
|
||||||
|
'access_token' => $pendingData['token'],
|
||||||
|
'refresh_token' => $pendingData['refresh_token'],
|
||||||
|
'token_expires_at' => $pendingData['expires_in'] ? now()->addSeconds($pendingData['expires_in']) : null,
|
||||||
|
'meta' => [
|
||||||
|
'organization_id' => $request->organization_id,
|
||||||
|
'admin_user_id' => $pendingData['user_id'],
|
||||||
|
'admin_name' => $pendingData['name'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->forget(['social_connect_workspace', 'linkedin_page_pending']);
|
||||||
|
|
||||||
|
return redirect()->route('workspaces.accounts', $workspace)
|
||||||
|
->with('success', 'LinkedIn Page conectada com sucesso!');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('LinkedIn Page selection error', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('workspaces.accounts', $workspace)
|
||||||
|
->with('error', 'Erro ao conectar página. Tente novamente.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchOrganizations(string $accessToken): array
|
||||||
|
{
|
||||||
|
$response = Http::withToken($accessToken)
|
||||||
|
->get('https://api.linkedin.com/v2/organizationAcls', [
|
||||||
|
'q' => 'roleAssignee',
|
||||||
|
'role' => 'ADMINISTRATOR',
|
||||||
|
'projection' => '(elements*(organization~(id,localizedName,vanityName,logoV2(original~:playableStreams))))',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
Log::error('LinkedIn Organizations fetch error', [
|
||||||
|
'error' => $response->body(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
$organizations = [];
|
||||||
|
|
||||||
|
foreach ($data['elements'] ?? [] as $element) {
|
||||||
|
$org = $element['organization~'] ?? null;
|
||||||
|
if ($org) {
|
||||||
|
$logoUrl = null;
|
||||||
|
if (isset($org['logoV2']['original~']['elements'][0]['identifiers'][0]['identifier'])) {
|
||||||
|
$logoUrl = $org['logoV2']['original~']['elements'][0]['identifiers'][0]['identifier'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$organizations[] = [
|
||||||
|
'id' => $org['id'],
|
||||||
|
'name' => $org['localizedName'] ?? 'Unknown',
|
||||||
|
'vanity_name' => $org['vanityName'] ?? null,
|
||||||
|
'logo' => $logoUrl,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $organizations;
|
||||||
|
}
|
||||||
|
}
|
||||||
125
app/Http/Controllers/Auth/SocialController.php
Normal file
125
app/Http/Controllers/Auth/SocialController.php
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Enums\SocialPlatform;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\SocialAccount;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
|
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||||
|
|
||||||
|
class SocialController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Workspace $workspace): Response
|
||||||
|
{
|
||||||
|
$this->authorize('view', $workspace);
|
||||||
|
|
||||||
|
$connectedAccounts = $workspace->socialAccounts;
|
||||||
|
|
||||||
|
$platforms = collect(SocialPlatform::cases())->map(function ($platform) use ($connectedAccounts) {
|
||||||
|
$connected = $connectedAccounts->firstWhere('platform', $platform);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'value' => $platform->value,
|
||||||
|
'label' => $platform->label(),
|
||||||
|
'color' => $platform->color(),
|
||||||
|
'connected' => $connected !== null,
|
||||||
|
'account' => $connected,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return Inertia::render('accounts/Index', [
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'platforms' => $platforms,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function disconnect(Workspace $workspace, SocialAccount $account): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', $workspace);
|
||||||
|
|
||||||
|
if ($account->workspace_id !== $workspace->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$account->delete();
|
||||||
|
|
||||||
|
return back()->with('success', 'Conta desconectada com sucesso!');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function redirectToProvider(Workspace $workspace, string $driver, array $scopes): SymfonyResponse
|
||||||
|
{
|
||||||
|
session(['social_connect_workspace' => $workspace->id]);
|
||||||
|
|
||||||
|
return Inertia::location(
|
||||||
|
Socialite::driver($driver)
|
||||||
|
->scopes($scopes)
|
||||||
|
->redirect()
|
||||||
|
->getTargetUrl()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function handleCallback(
|
||||||
|
Request $request,
|
||||||
|
SocialPlatform $platform,
|
||||||
|
string $driver
|
||||||
|
): RedirectResponse {
|
||||||
|
$workspaceId = session('social_connect_workspace');
|
||||||
|
|
||||||
|
if (! $workspaceId) {
|
||||||
|
return redirect()->route('workspaces.index')
|
||||||
|
->with('error', 'Sessão expirada. Tente novamente.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::find($workspaceId);
|
||||||
|
|
||||||
|
if (! $workspace || ! $request->user()->can('update', $workspace)) {
|
||||||
|
return redirect()->route('workspaces.index')
|
||||||
|
->with('error', 'Workspace não encontrado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($workspace->hasConnectedPlatform($platform->value)) {
|
||||||
|
return redirect()->route('workspaces.accounts', $workspace)
|
||||||
|
->with('error', 'Esta plataforma já está conectada.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$socialUser = Socialite::driver($driver)->user();
|
||||||
|
$avatarPath = uploadFromUrl($socialUser->getAvatar());
|
||||||
|
|
||||||
|
$workspace->socialAccounts()->create([
|
||||||
|
'platform' => $platform->value,
|
||||||
|
'platform_user_id' => $socialUser->getId(),
|
||||||
|
'username' => $socialUser->getNickname(),
|
||||||
|
'display_name' => $socialUser->getName(),
|
||||||
|
'avatar_url' => $avatarPath,
|
||||||
|
'access_token' => $socialUser->token,
|
||||||
|
'refresh_token' => $socialUser->refreshToken,
|
||||||
|
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
|
||||||
|
'scopes' => $socialUser->approvedScopes ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->forget('social_connect_workspace');
|
||||||
|
|
||||||
|
return redirect()->route('workspaces.accounts', $workspace)
|
||||||
|
->with('success', 'Conta conectada com sucesso!');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Social OAuth Error', [
|
||||||
|
'platform' => $platform->value,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('workspaces.accounts', $workspace)
|
||||||
|
->with('error', 'Erro ao conectar conta. Tente novamente.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
97
app/Http/Controllers/Auth/TikTokController.php
Normal file
97
app/Http/Controllers/Auth/TikTokController.php
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Enums\SocialPlatform;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class TikTokController extends SocialController
|
||||||
|
{
|
||||||
|
protected string $driver = 'tiktok';
|
||||||
|
|
||||||
|
protected SocialPlatform $platform = SocialPlatform::TikTok;
|
||||||
|
|
||||||
|
protected array $scopes = [
|
||||||
|
'user.info.basic',
|
||||||
|
'user.info.profile',
|
||||||
|
'video.publish',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function connect(Request $request, Workspace $workspace): Response
|
||||||
|
{
|
||||||
|
$this->authorize('update', $workspace);
|
||||||
|
|
||||||
|
if ($workspace->hasConnectedPlatform($this->platform->value)) {
|
||||||
|
return back()->with('error', 'Esta plataforma já está conectada.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToProvider($workspace, $this->driver, $this->scopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function callback(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$workspaceId = session('social_connect_workspace');
|
||||||
|
|
||||||
|
if (! $workspaceId) {
|
||||||
|
return redirect()->route('workspaces.index')
|
||||||
|
->with('error', 'Sessão expirada. Tente novamente.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::find($workspaceId);
|
||||||
|
|
||||||
|
if (! $workspace || ! $request->user()->can('update', $workspace)) {
|
||||||
|
return redirect()->route('workspaces.index')
|
||||||
|
->with('error', 'Workspace não encontrado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($workspace->hasConnectedPlatform($this->platform->value)) {
|
||||||
|
return redirect()->route('workspaces.accounts', $workspace)
|
||||||
|
->with('error', 'Esta plataforma já está conectada.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$socialUser = Socialite::driver($this->driver)
|
||||||
|
->scopes($this->scopes)
|
||||||
|
->user();
|
||||||
|
|
||||||
|
Log::info('TikTok OAuth User Data', [
|
||||||
|
'nickname' => $socialUser->getNickname(),
|
||||||
|
'user' => $socialUser->user ?? [],
|
||||||
|
'attributes' => $socialUser->attributes ?? [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// TikTok returns username via getNickname() when user.info.profile scope is included
|
||||||
|
$username = $socialUser->getNickname();
|
||||||
|
$avatarPath = uploadFromUrl($socialUser->getAvatar());
|
||||||
|
|
||||||
|
$workspace->socialAccounts()->create([
|
||||||
|
'platform' => $this->platform->value,
|
||||||
|
'platform_user_id' => $socialUser->getId(),
|
||||||
|
'username' => $username,
|
||||||
|
'display_name' => $socialUser->getName(),
|
||||||
|
'avatar_url' => $avatarPath,
|
||||||
|
'access_token' => $socialUser->token,
|
||||||
|
'refresh_token' => $socialUser->refreshToken,
|
||||||
|
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
|
||||||
|
'scopes' => $socialUser->approvedScopes ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->forget('social_connect_workspace');
|
||||||
|
|
||||||
|
return redirect()->route('workspaces.accounts', $workspace)
|
||||||
|
->with('success', 'Conta conectada com sucesso!');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('TikTok OAuth Error', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('workspaces.accounts', $workspace)
|
||||||
|
->with('error', 'Erro ao conectar conta. Tente novamente.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Http/Controllers/Auth/XController.php
Normal file
39
app/Http/Controllers/Auth/XController.php
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Enums\SocialPlatform;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class XController extends SocialController
|
||||||
|
{
|
||||||
|
protected string $driver = 'twitter';
|
||||||
|
|
||||||
|
protected SocialPlatform $platform = SocialPlatform::X;
|
||||||
|
|
||||||
|
protected array $scopes = [
|
||||||
|
'tweet.read',
|
||||||
|
'tweet.write',
|
||||||
|
'users.read',
|
||||||
|
'offline.access',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function connect(Request $request, Workspace $workspace): Response
|
||||||
|
{
|
||||||
|
$this->authorize('update', $workspace);
|
||||||
|
|
||||||
|
if ($workspace->hasConnectedPlatform($this->platform->value)) {
|
||||||
|
return back()->with('error', 'Esta plataforma já está conectada.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToProvider($workspace, $this->driver, $this->scopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function callback(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
return $this->handleCallback($request, $this->platform, $this->driver);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
app/Http/Controllers/BillingController.php
Normal file
104
app/Http/Controllers/BillingController.php
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class BillingController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the billing dashboard.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
return Inertia::render('billing/Index', [
|
||||||
|
'hasSubscription' => $user->hasActiveSubscription(),
|
||||||
|
'subscription' => $user->subscription('default')?->only([
|
||||||
|
'stripe_status',
|
||||||
|
'quantity',
|
||||||
|
'ends_at',
|
||||||
|
]),
|
||||||
|
'workspacesCount' => $user->ownedWorkspacesCount(),
|
||||||
|
'invoices' => $user->invoices()->map(fn ($invoice) => [
|
||||||
|
'id' => $invoice->id,
|
||||||
|
'date' => $invoice->date()->toFormattedDateString(),
|
||||||
|
'total' => $invoice->total(),
|
||||||
|
'status' => $invoice->status,
|
||||||
|
'invoice_pdf' => $invoice->invoice_pdf,
|
||||||
|
]),
|
||||||
|
'defaultPaymentMethod' => $user->defaultPaymentMethod()?->card?->only([
|
||||||
|
'brand',
|
||||||
|
'last4',
|
||||||
|
'exp_month',
|
||||||
|
'exp_year',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Stripe Checkout session for new subscription.
|
||||||
|
*/
|
||||||
|
public function checkout(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Calculate quantity based on workspaces (minimum 1)
|
||||||
|
$quantity = max(1, $user->ownedWorkspacesCount());
|
||||||
|
|
||||||
|
return $user->newSubscription('default', config('services.stripe.price_id'))
|
||||||
|
->quantity($quantity)
|
||||||
|
->checkout([
|
||||||
|
'success_url' => route('billing.index') . '?checkout=success',
|
||||||
|
'cancel_url' => route('billing.index') . '?checkout=cancelled',
|
||||||
|
])
|
||||||
|
->redirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect to Stripe Customer Portal.
|
||||||
|
*/
|
||||||
|
public function portal(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
return $request->user()->redirectToBillingPortal(
|
||||||
|
route('billing.index')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a workspace to subscription (increment quantity).
|
||||||
|
*/
|
||||||
|
public function addWorkspace(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $user->hasActiveSubscription()) {
|
||||||
|
return redirect()->route('billing.index')
|
||||||
|
->withErrors(['subscription' => 'Você precisa de uma assinatura ativa.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->incrementWorkspaceQuantity();
|
||||||
|
|
||||||
|
return back()->with('success', 'Workspace adicionado à assinatura.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a workspace from subscription (decrement quantity).
|
||||||
|
*/
|
||||||
|
public function removeWorkspace(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $user->hasActiveSubscription()) {
|
||||||
|
return back();
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->decrementWorkspaceQuantity();
|
||||||
|
|
||||||
|
return back()->with('success', 'Workspace removido da assinatura.');
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/Http/Controllers/Controller.php
Normal file
10
app/Http/Controllers/Controller.php
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
}
|
||||||
77
app/Http/Controllers/MediaController.php
Normal file
77
app/Http/Controllers/MediaController.php
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\MediaType;
|
||||||
|
use App\Http\Requests\StoreMediaRequest;
|
||||||
|
use App\Models\PostMedia;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class MediaController extends Controller
|
||||||
|
{
|
||||||
|
public function store(StoreMediaRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$file = $request->file('file');
|
||||||
|
$mimeType = $file->getMimeType();
|
||||||
|
|
||||||
|
$type = $this->getMediaType($mimeType);
|
||||||
|
|
||||||
|
$path = $file->store('media/' . now()->format('Y/m'), 'r2');
|
||||||
|
|
||||||
|
$media = PostMedia::create([
|
||||||
|
'post_platform_id' => null,
|
||||||
|
'type' => $type,
|
||||||
|
'disk' => 'r2',
|
||||||
|
'path' => $path,
|
||||||
|
'original_filename' => $file->getClientOriginalName(),
|
||||||
|
'mime_type' => $mimeType,
|
||||||
|
'size' => $file->getSize(),
|
||||||
|
'order' => 0,
|
||||||
|
'meta' => $this->getMediaMeta($file, $type),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'id' => $media->id,
|
||||||
|
'url' => $media->url,
|
||||||
|
'type' => $media->type->value,
|
||||||
|
'original_filename' => $media->original_filename,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(PostMedia $media): JsonResponse
|
||||||
|
{
|
||||||
|
$media->delete();
|
||||||
|
|
||||||
|
return response()->json(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getMediaType(string $mimeType): MediaType
|
||||||
|
{
|
||||||
|
if (str_starts_with($mimeType, 'image/')) {
|
||||||
|
return MediaType::Image;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($mimeType, 'video/')) {
|
||||||
|
return MediaType::Video;
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaType::Document;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getMediaMeta($file, MediaType $type): array
|
||||||
|
{
|
||||||
|
$meta = [];
|
||||||
|
|
||||||
|
if ($type === MediaType::Image) {
|
||||||
|
$imageInfo = @getimagesize($file->getPathname());
|
||||||
|
if ($imageInfo) {
|
||||||
|
$meta['width'] = $imageInfo[0];
|
||||||
|
$meta['height'] = $imageInfo[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $meta;
|
||||||
|
}
|
||||||
|
}
|
||||||
193
app/Http/Controllers/PostController.php
Normal file
193
app/Http/Controllers/PostController.php
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\PostStatus;
|
||||||
|
use App\Http\Requests\StorePostRequest;
|
||||||
|
use App\Http\Requests\UpdatePostRequest;
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class PostController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Workspace $workspace): Response
|
||||||
|
{
|
||||||
|
$this->authorize('view', $workspace);
|
||||||
|
|
||||||
|
$posts = $workspace->posts()
|
||||||
|
->with(['postPlatforms.socialAccount', 'user'])
|
||||||
|
->latest('scheduled_at')
|
||||||
|
->paginate(20);
|
||||||
|
|
||||||
|
return Inertia::render('posts/Index', [
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'posts' => $posts,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function calendar(Request $request, Workspace $workspace): Response
|
||||||
|
{
|
||||||
|
$this->authorize('view', $workspace);
|
||||||
|
|
||||||
|
$month = $request->input('month', now()->month);
|
||||||
|
$year = $request->input('year', now()->year);
|
||||||
|
|
||||||
|
$startOfMonth = Carbon::create($year, $month, 1)->startOfMonth();
|
||||||
|
$endOfMonth = Carbon::create($year, $month, 1)->endOfMonth();
|
||||||
|
|
||||||
|
$posts = $workspace->posts()
|
||||||
|
->with(['postPlatforms.socialAccount'])
|
||||||
|
->whereBetween('scheduled_at', [$startOfMonth, $endOfMonth])
|
||||||
|
->orderBy('scheduled_at')
|
||||||
|
->get()
|
||||||
|
->groupBy(fn ($post) => $post->scheduled_at?->format('Y-m-d'));
|
||||||
|
|
||||||
|
$socialAccounts = $workspace->socialAccounts;
|
||||||
|
|
||||||
|
return Inertia::render('posts/Calendar', [
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'posts' => $posts,
|
||||||
|
'socialAccounts' => $socialAccounts,
|
||||||
|
'currentMonth' => $month,
|
||||||
|
'currentYear' => $year,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request, Workspace $workspace): Response|RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('view', $workspace);
|
||||||
|
|
||||||
|
$socialAccounts = $workspace->socialAccounts;
|
||||||
|
|
||||||
|
if ($socialAccounts->isEmpty()) {
|
||||||
|
return redirect()->route('workspaces.accounts', $workspace)
|
||||||
|
->with('error', 'Conecte pelo menos uma rede social antes de criar um post.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('posts/Create', [
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'socialAccounts' => $socialAccounts,
|
||||||
|
'scheduledDate' => $request->input('date'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StorePostRequest $request, Workspace $workspace): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('view', $workspace);
|
||||||
|
|
||||||
|
$post = $workspace->posts()->create([
|
||||||
|
'user_id' => $request->user()->id,
|
||||||
|
'status' => $request->input('status', PostStatus::Draft),
|
||||||
|
'scheduled_at' => $request->input('scheduled_at'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($request->input('platforms', []) as $platformData) {
|
||||||
|
$postPlatform = $post->postPlatforms()->create([
|
||||||
|
'social_account_id' => $platformData['social_account_id'],
|
||||||
|
'platform' => $platformData['platform'],
|
||||||
|
'content' => $platformData['content'],
|
||||||
|
'status' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! empty($platformData['media_ids'])) {
|
||||||
|
$postPlatform->media()->whereIn('id', $platformData['media_ids'])->update([
|
||||||
|
'post_platform_id' => $postPlatform->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$route = $request->input('status') === PostStatus::Scheduled->value
|
||||||
|
? 'workspaces.calendar'
|
||||||
|
: 'workspaces.posts.index';
|
||||||
|
|
||||||
|
return redirect()->route($route, $workspace)
|
||||||
|
->with('success', 'Post criado com sucesso!');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Workspace $workspace, Post $post): Response
|
||||||
|
{
|
||||||
|
$this->authorize('view', $workspace);
|
||||||
|
|
||||||
|
if ($post->workspace_id !== $workspace->id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$post->load(['postPlatforms.socialAccount', 'postPlatforms.media', 'user']);
|
||||||
|
|
||||||
|
return Inertia::render('posts/Show', [
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'post' => $post,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Workspace $workspace, Post $post): Response|RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('view', $workspace);
|
||||||
|
|
||||||
|
if ($post->workspace_id !== $workspace->id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($post->status === PostStatus::Published) {
|
||||||
|
return redirect()->route('workspaces.posts.show', [$workspace, $post])
|
||||||
|
->with('error', 'Posts publicados não podem ser editados.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$post->load(['postPlatforms.socialAccount', 'postPlatforms.media']);
|
||||||
|
$socialAccounts = $workspace->socialAccounts;
|
||||||
|
|
||||||
|
return Inertia::render('posts/Edit', [
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'post' => $post,
|
||||||
|
'socialAccounts' => $socialAccounts,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdatePostRequest $request, Workspace $workspace, Post $post): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('view', $workspace);
|
||||||
|
|
||||||
|
if ($post->workspace_id !== $workspace->id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($post->status === PostStatus::Published) {
|
||||||
|
return back()->with('error', 'Posts publicados não podem ser editados.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$post->update([
|
||||||
|
'status' => $request->input('status', $post->status),
|
||||||
|
'scheduled_at' => $request->input('scheduled_at', $post->scheduled_at),
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($request->input('platforms', []) as $platformData) {
|
||||||
|
$post->postPlatforms()
|
||||||
|
->where('id', $platformData['id'])
|
||||||
|
->update([
|
||||||
|
'content' => $platformData['content'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('workspaces.posts.show', [$workspace, $post])
|
||||||
|
->with('success', 'Post atualizado com sucesso!');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Workspace $workspace, Post $post): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('view', $workspace);
|
||||||
|
|
||||||
|
if ($post->workspace_id !== $workspace->id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$post->delete();
|
||||||
|
|
||||||
|
return redirect()->route('workspaces.calendar', $workspace)
|
||||||
|
->with('success', 'Post excluído com sucesso!');
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Http/Controllers/Settings/PasswordController.php
Normal file
32
app/Http/Controllers/Settings/PasswordController.php
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Settings;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Settings\PasswordUpdateRequest;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class PasswordController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the user's password settings page.
|
||||||
|
*/
|
||||||
|
public function edit(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('settings/Password');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the user's password.
|
||||||
|
*/
|
||||||
|
public function update(PasswordUpdateRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->user()->update([
|
||||||
|
'password' => $request->password,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back();
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/Http/Controllers/Settings/ProfileController.php
Normal file
60
app/Http/Controllers/Settings/ProfileController.php
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Settings;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Settings\ProfileDeleteRequest;
|
||||||
|
use App\Http\Requests\Settings\ProfileUpdateRequest;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class ProfileController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the user's profile settings page.
|
||||||
|
*/
|
||||||
|
public function edit(Request $request): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('settings/Profile', [
|
||||||
|
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
|
||||||
|
'status' => $request->session()->get('status'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the user's profile information.
|
||||||
|
*/
|
||||||
|
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->user()->fill($request->validated());
|
||||||
|
|
||||||
|
if ($request->user()->isDirty('email')) {
|
||||||
|
$request->user()->email_verified_at = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->user()->save();
|
||||||
|
|
||||||
|
return to_route('profile.edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the user's profile.
|
||||||
|
*/
|
||||||
|
public function destroy(ProfileDeleteRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
Auth::logout();
|
||||||
|
|
||||||
|
$user->delete();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Settings;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Settings\TwoFactorAuthenticationRequest;
|
||||||
|
use Illuminate\Routing\Controllers\HasMiddleware;
|
||||||
|
use Illuminate\Routing\Controllers\Middleware;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
use Laravel\Fortify\Features;
|
||||||
|
|
||||||
|
class TwoFactorAuthenticationController extends Controller implements HasMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the middleware that should be assigned to the controller.
|
||||||
|
*/
|
||||||
|
public static function middleware(): array
|
||||||
|
{
|
||||||
|
return Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword')
|
||||||
|
? [new Middleware('password.confirm', only: ['show'])]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the user's two-factor authentication settings page.
|
||||||
|
*/
|
||||||
|
public function show(TwoFactorAuthenticationRequest $request): Response
|
||||||
|
{
|
||||||
|
$request->ensureStateIsValid();
|
||||||
|
|
||||||
|
return Inertia::render('settings/TwoFactor', [
|
||||||
|
'twoFactorEnabled' => $request->user()->hasEnabledTwoFactorAuthentication(),
|
||||||
|
'requiresConfirmation' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
120
app/Http/Controllers/WorkspaceController.php
Normal file
120
app/Http/Controllers/WorkspaceController.php
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\StoreWorkspaceRequest;
|
||||||
|
use App\Http\Requests\UpdateWorkspaceRequest;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class WorkspaceController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$workspaces = $request->user()
|
||||||
|
->workspaces()
|
||||||
|
->withCount(['socialAccounts', 'posts'])
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return Inertia::render('workspaces/Index', [
|
||||||
|
'workspaces' => $workspaces,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): Response|RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// First workspace is free, subsequent ones require subscription
|
||||||
|
if ($user->ownedWorkspacesCount() > 0 && ! $user->hasActiveSubscription()) {
|
||||||
|
return redirect()->route('billing.index')
|
||||||
|
->with('message', 'Assine para criar mais workspaces.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('workspaces/Create');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreWorkspaceRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// First workspace is free, subsequent ones require subscription
|
||||||
|
if ($user->ownedWorkspacesCount() > 0 && ! $user->hasActiveSubscription()) {
|
||||||
|
return redirect()->route('billing.index')
|
||||||
|
->with('message', 'Assine para criar mais workspaces.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = $user->workspaces()->create($request->validated());
|
||||||
|
|
||||||
|
$workspace->members()->attach($user->id, ['role' => 'owner']);
|
||||||
|
|
||||||
|
// Increment subscription quantity if user has subscription
|
||||||
|
if ($user->hasActiveSubscription()) {
|
||||||
|
$user->incrementWorkspaceQuantity();
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('workspaces.show', $workspace)
|
||||||
|
->with('success', 'Workspace criado com sucesso!');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Workspace $workspace): Response
|
||||||
|
{
|
||||||
|
$this->authorize('view', $workspace);
|
||||||
|
|
||||||
|
$workspace->load(['socialAccounts', 'posts' => function ($query) {
|
||||||
|
$query->latest()->take(5);
|
||||||
|
}]);
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'total_posts' => $workspace->posts()->count(),
|
||||||
|
'scheduled_posts' => $workspace->posts()->scheduled()->count(),
|
||||||
|
'published_posts' => $workspace->posts()->published()->count(),
|
||||||
|
'connected_accounts' => $workspace->socialAccounts()->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Inertia::render('workspaces/Show', [
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'stats' => $stats,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Workspace $workspace): Response
|
||||||
|
{
|
||||||
|
$this->authorize('update', $workspace);
|
||||||
|
|
||||||
|
return Inertia::render('workspaces/Edit', [
|
||||||
|
'workspace' => $workspace,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateWorkspaceRequest $request, Workspace $workspace): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', $workspace);
|
||||||
|
|
||||||
|
$workspace->update($request->validated());
|
||||||
|
|
||||||
|
return redirect()->route('workspaces.show', $workspace)
|
||||||
|
->with('success', 'Workspace atualizado com sucesso!');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Workspace $workspace): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $workspace);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$workspace->delete();
|
||||||
|
|
||||||
|
// Decrement subscription quantity if user has subscription
|
||||||
|
if ($user->hasActiveSubscription()) {
|
||||||
|
$user->decrementWorkspaceQuantity();
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('workspaces.index')
|
||||||
|
->with('success', 'Workspace excluído com sucesso!');
|
||||||
|
}
|
||||||
|
}
|
||||||
140
app/Http/Controllers/WorkspaceInviteController.php
Normal file
140
app/Http/Controllers/WorkspaceInviteController.php
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\InviteStatus;
|
||||||
|
use App\Enums\WorkspaceRole;
|
||||||
|
use App\Http\Requests\StoreWorkspaceInviteRequest;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceInvite;
|
||||||
|
use App\Notifications\WorkspaceInviteNotification;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class WorkspaceInviteController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Workspace $workspace): Response
|
||||||
|
{
|
||||||
|
$this->authorize('update', $workspace);
|
||||||
|
|
||||||
|
return Inertia::render('workspaces/Invites', [
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'invites' => $workspace->invites()
|
||||||
|
->with('inviter')
|
||||||
|
->latest()
|
||||||
|
->get(),
|
||||||
|
'members' => $workspace->members()->get()->map(fn ($member) => [
|
||||||
|
'id' => $member->id,
|
||||||
|
'name' => $member->name,
|
||||||
|
'email' => $member->email,
|
||||||
|
'role' => $member->pivot->role,
|
||||||
|
]),
|
||||||
|
'owner' => [
|
||||||
|
'id' => $workspace->owner->id,
|
||||||
|
'name' => $workspace->owner->name,
|
||||||
|
'email' => $workspace->owner->email,
|
||||||
|
'role' => WorkspaceRole::Owner->value,
|
||||||
|
],
|
||||||
|
'roles' => collect(WorkspaceRole::cases())
|
||||||
|
->filter(fn ($role) => $role !== WorkspaceRole::Owner)
|
||||||
|
->map(fn ($role) => [
|
||||||
|
'value' => $role->value,
|
||||||
|
'label' => $role->label(),
|
||||||
|
])->values(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreWorkspaceInviteRequest $request, Workspace $workspace): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', $workspace);
|
||||||
|
|
||||||
|
$existingInvite = $workspace->invites()
|
||||||
|
->where('email', $request->email)
|
||||||
|
->pending()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existingInvite) {
|
||||||
|
return back()->withErrors([
|
||||||
|
'email' => 'Já existe um convite pendente para este email.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($workspace->members()->where('email', $request->email)->exists()) {
|
||||||
|
return back()->withErrors([
|
||||||
|
'email' => 'Este usuário já é membro do workspace.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$invite = $workspace->invites()->create([
|
||||||
|
'invited_by' => $request->user()->id,
|
||||||
|
'email' => $request->email,
|
||||||
|
'role' => $request->role ?? WorkspaceRole::Member,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$invite->notify(new WorkspaceInviteNotification($invite));
|
||||||
|
|
||||||
|
return back()->with('success', 'Convite enviado com sucesso!');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Workspace $workspace, WorkspaceInvite $invite): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', $workspace);
|
||||||
|
|
||||||
|
if ($invite->workspace_id !== $workspace->id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$invite->cancel();
|
||||||
|
|
||||||
|
return back()->with('success', 'Convite cancelado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function accept(Request $request, string $token): RedirectResponse
|
||||||
|
{
|
||||||
|
$invite = WorkspaceInvite::where('token', $token)->firstOrFail();
|
||||||
|
|
||||||
|
if (! $invite->isValid()) {
|
||||||
|
if ($invite->isExpired()) {
|
||||||
|
return redirect()->route('workspaces.index')
|
||||||
|
->withErrors(['invite' => 'Este convite expirou.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('workspaces.index')
|
||||||
|
->withErrors(['invite' => 'Este convite não é mais válido.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
session(['pending_invite_token' => $token]);
|
||||||
|
|
||||||
|
return redirect()->route('login')
|
||||||
|
->with('message', 'Faça login para aceitar o convite.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($invite->workspace->hasMember($user)) {
|
||||||
|
return redirect()->route('workspaces.show', $invite->workspace)
|
||||||
|
->with('message', 'Você já é membro deste workspace.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$invite->accept($user);
|
||||||
|
|
||||||
|
return redirect()->route('workspaces.show', $invite->workspace)
|
||||||
|
->with('success', 'Você agora é membro do workspace!');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeMember(Workspace $workspace, string $userId): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', $workspace);
|
||||||
|
|
||||||
|
if ($workspace->user_id === $userId) {
|
||||||
|
return back()->withErrors(['member' => 'Não é possível remover o dono do workspace.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace->members()->detach($userId);
|
||||||
|
|
||||||
|
return back()->with('success', 'Membro removido com sucesso.');
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Http/Middleware/HandleAppearance.php
Normal file
23
app/Http/Middleware/HandleAppearance.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\View;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class HandleAppearance
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
View::share('appearance', $request->cookie('appearance') ?? 'system');
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Http/Middleware/HandleInertiaRequests.php
Normal file
47
app/Http/Middleware/HandleInertiaRequests.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Middleware;
|
||||||
|
|
||||||
|
class HandleInertiaRequests extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The root template that's loaded on the first page visit.
|
||||||
|
*
|
||||||
|
* @see https://inertiajs.com/server-side-setup#root-template
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $rootView = 'app';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the current asset version.
|
||||||
|
*
|
||||||
|
* @see https://inertiajs.com/asset-versioning
|
||||||
|
*/
|
||||||
|
public function version(Request $request): ?string
|
||||||
|
{
|
||||||
|
return parent::version($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the props that are shared by default.
|
||||||
|
*
|
||||||
|
* @see https://inertiajs.com/shared-data
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function share(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
...parent::share($request),
|
||||||
|
'name' => config('app.name'),
|
||||||
|
'auth' => [
|
||||||
|
'user' => $request->user(),
|
||||||
|
],
|
||||||
|
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Http/Requests/Settings/PasswordUpdateRequest.php
Normal file
25
app/Http/Requests/Settings/PasswordUpdateRequest.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Settings;
|
||||||
|
|
||||||
|
use App\Concerns\PasswordValidationRules;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class PasswordUpdateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use PasswordValidationRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'current_password' => $this->currentPasswordRules(),
|
||||||
|
'password' => $this->passwordRules(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Http/Requests/Settings/ProfileDeleteRequest.php
Normal file
23
app/Http/Requests/Settings/ProfileDeleteRequest.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Settings;
|
||||||
|
|
||||||
|
use App\Concerns\PasswordValidationRules;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class ProfileDeleteRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use PasswordValidationRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'password' => $this->currentPasswordRules(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Http/Requests/Settings/ProfileUpdateRequest.php
Normal file
22
app/Http/Requests/Settings/ProfileUpdateRequest.php
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Settings;
|
||||||
|
|
||||||
|
use App\Concerns\ProfileValidationRules;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class ProfileUpdateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use ProfileValidationRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return $this->profileRules($this->user()->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Settings;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Laravel\Fortify\Features;
|
||||||
|
use Laravel\Fortify\InteractsWithTwoFactorState;
|
||||||
|
|
||||||
|
class TwoFactorAuthenticationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use InteractsWithTwoFactorState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return Features::enabled(Features::twoFactorAuthentication());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Http/Requests/StoreMediaRequest.php
Normal file
34
app/Http/Requests/StoreMediaRequest.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreMediaRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'file' => [
|
||||||
|
'required',
|
||||||
|
'file',
|
||||||
|
'max:512000', // 500MB
|
||||||
|
'mimetypes:image/jpeg,image/png,image/gif,image/webp,video/mp4,video/quicktime,video/webm,application/pdf',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'file.required' => 'O arquivo é obrigatório.',
|
||||||
|
'file.max' => 'O arquivo deve ter no máximo 500MB.',
|
||||||
|
'file.mimetypes' => 'Tipo de arquivo não suportado.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Http/Requests/StorePostRequest.php
Normal file
39
app/Http/Requests/StorePostRequest.php
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\PostStatus;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StorePostRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => ['required', Rule::enum(PostStatus::class)],
|
||||||
|
'scheduled_at' => ['required_if:status,' . PostStatus::Scheduled->value, 'nullable', 'date', 'after:now'],
|
||||||
|
'platforms' => ['required', 'array', 'min:1'],
|
||||||
|
'platforms.*.social_account_id' => ['required', 'uuid', 'exists:social_accounts,id'],
|
||||||
|
'platforms.*.platform' => ['required', 'string'],
|
||||||
|
'platforms.*.content' => ['nullable', 'string', 'max:5000'],
|
||||||
|
'platforms.*.media_ids' => ['nullable', 'array'],
|
||||||
|
'platforms.*.media_ids.*' => ['uuid', 'exists:post_media,id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'platforms.required' => 'Selecione pelo menos uma rede social.',
|
||||||
|
'platforms.min' => 'Selecione pelo menos uma rede social.',
|
||||||
|
'scheduled_at.required_if' => 'A data de agendamento é obrigatória para posts agendados.',
|
||||||
|
'scheduled_at.after' => 'A data de agendamento deve ser no futuro.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Http/Requests/StoreWorkspaceInviteRequest.php
Normal file
45
app/Http/Requests/StoreWorkspaceInviteRequest.php
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\WorkspaceRole;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreWorkspaceInviteRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'email', 'max:255'],
|
||||||
|
'role' => ['nullable', Rule::enum(WorkspaceRole::class)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get custom messages for validation errors.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email.required' => 'O email é obrigatório.',
|
||||||
|
'email.email' => 'Digite um email válido.',
|
||||||
|
'email.max' => 'O email deve ter no máximo 255 caracteres.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Http/Requests/StoreWorkspaceRequest.php
Normal file
28
app/Http/Requests/StoreWorkspaceRequest.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreWorkspaceRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name.required' => 'O nome do workspace é obrigatório.',
|
||||||
|
'name.max' => 'O nome do workspace deve ter no máximo 255 caracteres.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Http/Requests/UpdatePostRequest.php
Normal file
33
app/Http/Requests/UpdatePostRequest.php
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\PostStatus;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdatePostRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => ['sometimes', Rule::enum(PostStatus::class)],
|
||||||
|
'scheduled_at' => ['sometimes', 'nullable', 'date', 'after:now'],
|
||||||
|
'platforms' => ['sometimes', 'array'],
|
||||||
|
'platforms.*.id' => ['required', 'uuid', 'exists:post_platforms,id'],
|
||||||
|
'platforms.*.content' => ['nullable', 'string', 'max:5000'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'scheduled_at.after' => 'A data de agendamento deve ser no futuro.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Http/Requests/UpdateWorkspaceRequest.php
Normal file
28
app/Http/Requests/UpdateWorkspaceRequest.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateWorkspaceRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name.required' => 'O nome do workspace é obrigatório.',
|
||||||
|
'name.max' => 'O nome do workspace deve ter no máximo 255 caracteres.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Jobs/ProcessScheduledPosts.php
Normal file
24
app/Jobs/ProcessScheduledPosts.php
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Post;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
class ProcessScheduledPosts implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
Post::query()
|
||||||
|
->due()
|
||||||
|
->with(['postPlatforms.socialAccount', 'postPlatforms.media'])
|
||||||
|
->chunk(100, function ($posts) {
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
PublishPost::dispatch($post);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Jobs/PublishPost.php
Normal file
23
app/Jobs/PublishPost.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Post;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
class PublishPost implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(public Post $post) {}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$this->post->markAsPublishing();
|
||||||
|
|
||||||
|
foreach ($this->post->postPlatforms as $postPlatform) {
|
||||||
|
PublishToSocialPlatform::dispatch($postPlatform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/Jobs/PublishToSocialPlatform.php
Normal file
71
app/Jobs/PublishToSocialPlatform.php
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Enums\PostStatus;
|
||||||
|
use App\Enums\SocialPlatform;
|
||||||
|
use App\Models\PostPlatform;
|
||||||
|
use App\Services\Social\LinkedInPagePublisher;
|
||||||
|
use App\Services\Social\LinkedInPublisher;
|
||||||
|
use App\Services\Social\TikTokPublisher;
|
||||||
|
use App\Services\Social\XPublisher;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class PublishToSocialPlatform implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
public int $backoff = 60;
|
||||||
|
|
||||||
|
public function __construct(public PostPlatform $postPlatform) {}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$this->postPlatform->markAsPublishing();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$publisher = $this->getPublisher();
|
||||||
|
$result = $publisher->publish($this->postPlatform);
|
||||||
|
|
||||||
|
$this->postPlatform->markAsPublished($result['id'], $result['url'] ?? null);
|
||||||
|
|
||||||
|
$this->checkPostComplete();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Failed to publish to social platform', [
|
||||||
|
'post_platform_id' => $this->postPlatform->id,
|
||||||
|
'platform' => $this->postPlatform->platform->value,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postPlatform->markAsFailed($e->getMessage());
|
||||||
|
$this->postPlatform->post->markAsFailed();
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher
|
||||||
|
{
|
||||||
|
return match ($this->postPlatform->platform) {
|
||||||
|
SocialPlatform::LinkedIn => app(LinkedInPublisher::class),
|
||||||
|
SocialPlatform::LinkedInPage => app(LinkedInPagePublisher::class),
|
||||||
|
SocialPlatform::X => app(XPublisher::class),
|
||||||
|
SocialPlatform::TikTok => app(TikTokPublisher::class),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkPostComplete(): void
|
||||||
|
{
|
||||||
|
$post = $this->postPlatform->post->fresh();
|
||||||
|
|
||||||
|
$allPublished = $post->postPlatforms->every(fn ($pp) => $pp->status === 'published');
|
||||||
|
|
||||||
|
if ($allPublished) {
|
||||||
|
$post->markAsPublished();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/Models/Post.php
Normal file
92
app/Models/Post.php
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\PostStatus;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Post extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\PostFactory> */
|
||||||
|
use HasFactory, HasUuids;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'workspace_id',
|
||||||
|
'user_id',
|
||||||
|
'status',
|
||||||
|
'scheduled_at',
|
||||||
|
'published_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => PostStatus::class,
|
||||||
|
'scheduled_at' => 'datetime',
|
||||||
|
'published_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postPlatforms(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PostPlatform::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeScheduled(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('status', PostStatus::Scheduled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeDue(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->scheduled()->where('scheduled_at', '<=', now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeDraft(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('status', PostStatus::Draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopePublished(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('status', PostStatus::Published);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeFailed(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('status', PostStatus::Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsPublishing(): void
|
||||||
|
{
|
||||||
|
$this->update(['status' => PostStatus::Publishing]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsPublished(): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'status' => PostStatus::Published,
|
||||||
|
'published_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsFailed(): void
|
||||||
|
{
|
||||||
|
$this->update(['status' => PostStatus::Failed]);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/Models/PostMedia.php
Normal file
66
app/Models/PostMedia.php
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\MediaType;
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class PostMedia extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\PostMediaFactory> */
|
||||||
|
use HasFactory, HasUuids;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'post_platform_id',
|
||||||
|
'type',
|
||||||
|
'disk',
|
||||||
|
'path',
|
||||||
|
'original_filename',
|
||||||
|
'mime_type',
|
||||||
|
'size',
|
||||||
|
'order',
|
||||||
|
'meta',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => MediaType::class,
|
||||||
|
'size' => 'integer',
|
||||||
|
'order' => 'integer',
|
||||||
|
'meta' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postPlatform(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PostPlatform::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function url(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn () => Storage::disk($this->disk)->url($this->path),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTemporaryUrl(int $expirationMinutes = 60): string
|
||||||
|
{
|
||||||
|
return Storage::disk($this->disk)->temporaryUrl(
|
||||||
|
$this->path,
|
||||||
|
now()->addMinutes($expirationMinutes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(): bool
|
||||||
|
{
|
||||||
|
Storage::disk($this->disk)->delete($this->path);
|
||||||
|
|
||||||
|
return parent::delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/Models/PostPlatform.php
Normal file
76
app/Models/PostPlatform.php
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\SocialPlatform;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class PostPlatform extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\PostPlatformFactory> */
|
||||||
|
use HasFactory, HasUuids;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'post_id',
|
||||||
|
'social_account_id',
|
||||||
|
'platform',
|
||||||
|
'content',
|
||||||
|
'status',
|
||||||
|
'platform_post_id',
|
||||||
|
'platform_url',
|
||||||
|
'error_message',
|
||||||
|
'published_at',
|
||||||
|
'meta',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'platform' => SocialPlatform::class,
|
||||||
|
'published_at' => 'datetime',
|
||||||
|
'meta' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function post(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Post::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function socialAccount(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(SocialAccount::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function media(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PostMedia::class)->orderBy('order');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsPublishing(): void
|
||||||
|
{
|
||||||
|
$this->update(['status' => 'publishing']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsPublished(string $platformPostId, ?string $platformUrl = null): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'status' => 'published',
|
||||||
|
'platform_post_id' => $platformPostId,
|
||||||
|
'platform_url' => $platformUrl,
|
||||||
|
'published_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsFailed(string $errorMessage): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'status' => 'failed',
|
||||||
|
'error_message' => $errorMessage,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
app/Models/SocialAccount.php
Normal file
80
app/Models/SocialAccount.php
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\SocialPlatform;
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class SocialAccount extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\SocialAccountFactory> */
|
||||||
|
use HasFactory, HasUuids;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'workspace_id',
|
||||||
|
'platform',
|
||||||
|
'platform_user_id',
|
||||||
|
'username',
|
||||||
|
'display_name',
|
||||||
|
'avatar_url',
|
||||||
|
'access_token',
|
||||||
|
'refresh_token',
|
||||||
|
'token_expires_at',
|
||||||
|
'scopes',
|
||||||
|
'meta',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $hidden = [
|
||||||
|
'access_token',
|
||||||
|
'refresh_token',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'platform' => SocialPlatform::class,
|
||||||
|
'access_token' => 'encrypted',
|
||||||
|
'refresh_token' => 'encrypted',
|
||||||
|
'token_expires_at' => 'datetime',
|
||||||
|
'scopes' => 'array',
|
||||||
|
'meta' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postPlatforms(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PostPlatform::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isTokenExpired(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn () => $this->token_expires_at && $this->token_expires_at->isPast(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isTokenExpiringSoon(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn () => $this->token_expires_at && $this->token_expires_at->isBefore(now()->addHour()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function avatarUrl(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn (?string $value) => $value ? Storage::url($value) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
144
app/Models/User.php
Normal file
144
app/Models/User.php
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Cashier\Billable;
|
||||||
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
|
|
||||||
|
class User extends Authenticatable
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
|
use Billable, HasFactory, HasUuids, Notifiable, TwoFactorAuthenticatable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'email',
|
||||||
|
'password',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be hidden for serialization.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $hidden = [
|
||||||
|
'password',
|
||||||
|
'two_factor_secret',
|
||||||
|
'two_factor_recovery_codes',
|
||||||
|
'remember_token',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email_verified_at' => 'datetime',
|
||||||
|
'password' => 'hashed',
|
||||||
|
'two_factor_confirmed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get workspaces owned by this user.
|
||||||
|
*/
|
||||||
|
public function workspaces(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get workspaces where the user is a member (not owner).
|
||||||
|
*/
|
||||||
|
public function memberWorkspaces(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Workspace::class, 'workspace_members')
|
||||||
|
->using(WorkspaceMember::class)
|
||||||
|
->withPivot('role')
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the count of workspaces the user owns.
|
||||||
|
*/
|
||||||
|
public function ownedWorkspacesCount(): int
|
||||||
|
{
|
||||||
|
return $this->workspaces()->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has an active subscription.
|
||||||
|
*/
|
||||||
|
public function hasActiveSubscription(): bool
|
||||||
|
{
|
||||||
|
return $this->subscribed('default');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user can create more workspaces based on subscription.
|
||||||
|
*/
|
||||||
|
public function canCreateWorkspace(): bool
|
||||||
|
{
|
||||||
|
// If no subscription, allow first workspace free (or require subscription)
|
||||||
|
if (! $this->hasActiveSubscription()) {
|
||||||
|
return $this->ownedWorkspacesCount() === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subscription = $this->subscription('default');
|
||||||
|
|
||||||
|
return $subscription && $this->ownedWorkspacesCount() < $subscription->quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment workspace quantity on subscription.
|
||||||
|
*/
|
||||||
|
public function incrementWorkspaceQuantity(): void
|
||||||
|
{
|
||||||
|
if ($this->hasActiveSubscription()) {
|
||||||
|
$this->subscription('default')->incrementQuantity();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrement workspace quantity on subscription.
|
||||||
|
*/
|
||||||
|
public function decrementWorkspaceQuantity(): void
|
||||||
|
{
|
||||||
|
if ($this->hasActiveSubscription()) {
|
||||||
|
$subscription = $this->subscription('default');
|
||||||
|
|
||||||
|
if ($subscription->quantity > 1) {
|
||||||
|
$subscription->decrementQuantity();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync subscription quantity with actual workspace count.
|
||||||
|
*/
|
||||||
|
public function syncWorkspaceQuantity(): void
|
||||||
|
{
|
||||||
|
if ($this->hasActiveSubscription()) {
|
||||||
|
$count = $this->ownedWorkspacesCount();
|
||||||
|
|
||||||
|
if ($count > 0) {
|
||||||
|
$this->subscription('default')->updateQuantity($count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/Models/Workspace.php
Normal file
70
app/Models/Workspace.php
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\WorkspaceRole;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Workspace extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\WorkspaceFactory> */
|
||||||
|
use HasFactory, HasUuids;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'name',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function owner(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function members(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(User::class, 'workspace_members')
|
||||||
|
->using(WorkspaceMember::class)
|
||||||
|
->withPivot('role')
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function socialAccounts(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(SocialAccount::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function posts(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Post::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invites(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(WorkspaceInvite::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pendingInvites(): HasMany
|
||||||
|
{
|
||||||
|
return $this->invites()->pending();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasMember(User $user): bool
|
||||||
|
{
|
||||||
|
return $this->user_id === $user->id || $this->members()->where('user_id', $user->id)->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasConnectedPlatform(string $platform): bool
|
||||||
|
{
|
||||||
|
return $this->socialAccounts()->where('platform', $platform)->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSocialAccount(string $platform): ?SocialAccount
|
||||||
|
{
|
||||||
|
return $this->socialAccounts()->where('platform', $platform)->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
115
app/Models/WorkspaceInvite.php
Normal file
115
app/Models/WorkspaceInvite.php
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\InviteStatus;
|
||||||
|
use App\Enums\WorkspaceRole;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class WorkspaceInvite extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\WorkspaceInviteFactory> */
|
||||||
|
use HasFactory, HasUuids, Notifiable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'workspace_id',
|
||||||
|
'invited_by',
|
||||||
|
'email',
|
||||||
|
'token',
|
||||||
|
'role',
|
||||||
|
'status',
|
||||||
|
'expires_at',
|
||||||
|
'accepted_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'role' => WorkspaceRole::class,
|
||||||
|
'status' => InviteStatus::class,
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'accepted_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (WorkspaceInvite $invite) {
|
||||||
|
if (empty($invite->token)) {
|
||||||
|
$invite->token = Str::random(64);
|
||||||
|
}
|
||||||
|
if (empty($invite->expires_at)) {
|
||||||
|
$invite->expires_at = now()->addDays(7);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inviter(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'invited_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopePending(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('status', InviteStatus::Pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeValid(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->pending()->where('expires_at', '>', now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isExpired(): bool
|
||||||
|
{
|
||||||
|
return $this->expires_at->isPast();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPending(): bool
|
||||||
|
{
|
||||||
|
return $this->status === InviteStatus::Pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isValid(): bool
|
||||||
|
{
|
||||||
|
return $this->isPending() && ! $this->isExpired();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function accept(User $user): void
|
||||||
|
{
|
||||||
|
$this->workspace->members()->attach($user->id, [
|
||||||
|
'role' => $this->role,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->update([
|
||||||
|
'status' => InviteStatus::Accepted,
|
||||||
|
'accepted_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(): void
|
||||||
|
{
|
||||||
|
$this->update(['status' => InviteStatus::Cancelled]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function routeNotificationForMail(): string
|
||||||
|
{
|
||||||
|
return $this->email;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Models/WorkspaceMember.php
Normal file
17
app/Models/WorkspaceMember.php
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||||
|
|
||||||
|
class WorkspaceMember extends Pivot
|
||||||
|
{
|
||||||
|
use HasUuids;
|
||||||
|
|
||||||
|
protected $table = 'workspace_members';
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
}
|
||||||
63
app/Notifications/WorkspaceInviteNotification.php
Normal file
63
app/Notifications/WorkspaceInviteNotification.php
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\WorkspaceInvite;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class WorkspaceInviteNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new notification instance.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public WorkspaceInvite $invite
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification's delivery channels.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mail representation of the notification.
|
||||||
|
*/
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$acceptUrl = route('invites.accept', $this->invite->token);
|
||||||
|
|
||||||
|
return (new MailMessage)
|
||||||
|
->subject("Convite para o workspace {$this->invite->workspace->name}")
|
||||||
|
->greeting('Olá!')
|
||||||
|
->line("{$this->invite->inviter->name} convidou você para colaborar no workspace **{$this->invite->workspace->name}**.")
|
||||||
|
->line("Você foi convidado como **{$this->invite->role->label()}**.")
|
||||||
|
->action('Aceitar Convite', $acceptUrl)
|
||||||
|
->line('Este convite expira em 7 dias.')
|
||||||
|
->salutation('Obrigado por usar TryPost!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the array representation of the notification.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'workspace_id' => $this->invite->workspace_id,
|
||||||
|
'workspace_name' => $this->invite->workspace->name,
|
||||||
|
'inviter_name' => $this->invite->inviter->name,
|
||||||
|
'role' => $this->invite->role->value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Policies/WorkspacePolicy.php
Normal file
44
app/Policies/WorkspacePolicy.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
|
||||||
|
class WorkspacePolicy
|
||||||
|
{
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, Workspace $workspace): bool
|
||||||
|
{
|
||||||
|
return $user->id === $workspace->user_id || $workspace->members->contains($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, Workspace $workspace): bool
|
||||||
|
{
|
||||||
|
return $user->id === $workspace->user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Workspace $workspace): bool
|
||||||
|
{
|
||||||
|
return $user->id === $workspace->user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(User $user, Workspace $workspace): bool
|
||||||
|
{
|
||||||
|
return $user->id === $workspace->user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forceDelete(User $user, Workspace $workspace): bool
|
||||||
|
{
|
||||||
|
return $user->id === $workspace->user_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/Providers/AppServiceProvider.php
Normal file
62
app/Providers/AppServiceProvider.php
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Support\Facades\Date;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
use App\Socialite\LinkedInPageExtendSocialite;
|
||||||
|
use SocialiteProviders\LinkedIn\LinkedInExtendSocialite;
|
||||||
|
use SocialiteProviders\Manager\SocialiteWasCalled;
|
||||||
|
use SocialiteProviders\TikTok\TikTokExtendSocialite;
|
||||||
|
use SocialiteProviders\Twitter\TwitterExtendSocialite;
|
||||||
|
|
||||||
|
class AppServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register any application services.
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap any application services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
$this->configureDefaults();
|
||||||
|
$this->configureSocialite();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configureSocialite(): void
|
||||||
|
{
|
||||||
|
Event::listen(SocialiteWasCalled::class, LinkedInExtendSocialite::class);
|
||||||
|
Event::listen(SocialiteWasCalled::class, LinkedInPageExtendSocialite::class);
|
||||||
|
Event::listen(SocialiteWasCalled::class, TikTokExtendSocialite::class);
|
||||||
|
Event::listen(SocialiteWasCalled::class, TwitterExtendSocialite::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configureDefaults(): void
|
||||||
|
{
|
||||||
|
Date::use(CarbonImmutable::class);
|
||||||
|
|
||||||
|
DB::prohibitDestructiveCommands(
|
||||||
|
app()->isProduction(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Password::defaults(fn (): ?Password => app()->isProduction()
|
||||||
|
? Password::min(12)
|
||||||
|
->mixedCase()
|
||||||
|
->letters()
|
||||||
|
->numbers()
|
||||||
|
->symbols()
|
||||||
|
->uncompromised()
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
app/Providers/FortifyServiceProvider.php
Normal file
91
app/Providers/FortifyServiceProvider.php
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Actions\Fortify\CreateNewUser;
|
||||||
|
use App\Actions\Fortify\ResetUserPassword;
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Laravel\Fortify\Features;
|
||||||
|
use Laravel\Fortify\Fortify;
|
||||||
|
|
||||||
|
class FortifyServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register any application services.
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap any application services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
$this->configureActions();
|
||||||
|
$this->configureViews();
|
||||||
|
$this->configureRateLimiting();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure Fortify actions.
|
||||||
|
*/
|
||||||
|
private function configureActions(): void
|
||||||
|
{
|
||||||
|
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||||
|
Fortify::createUsersUsing(CreateNewUser::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure Fortify views.
|
||||||
|
*/
|
||||||
|
private function configureViews(): void
|
||||||
|
{
|
||||||
|
Fortify::loginView(fn (Request $request) => Inertia::render('auth/Login', [
|
||||||
|
'canResetPassword' => Features::enabled(Features::resetPasswords()),
|
||||||
|
'canRegister' => Features::enabled(Features::registration()),
|
||||||
|
'status' => $request->session()->get('status'),
|
||||||
|
]));
|
||||||
|
|
||||||
|
Fortify::resetPasswordView(fn (Request $request) => Inertia::render('auth/ResetPassword', [
|
||||||
|
'email' => $request->email,
|
||||||
|
'token' => $request->route('token'),
|
||||||
|
]));
|
||||||
|
|
||||||
|
Fortify::requestPasswordResetLinkView(fn (Request $request) => Inertia::render('auth/ForgotPassword', [
|
||||||
|
'status' => $request->session()->get('status'),
|
||||||
|
]));
|
||||||
|
|
||||||
|
Fortify::verifyEmailView(fn (Request $request) => Inertia::render('auth/VerifyEmail', [
|
||||||
|
'status' => $request->session()->get('status'),
|
||||||
|
]));
|
||||||
|
|
||||||
|
Fortify::registerView(fn () => Inertia::render('auth/Register'));
|
||||||
|
|
||||||
|
Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/TwoFactorChallenge'));
|
||||||
|
|
||||||
|
Fortify::confirmPasswordView(fn () => Inertia::render('auth/ConfirmPassword'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure rate limiting.
|
||||||
|
*/
|
||||||
|
private function configureRateLimiting(): void
|
||||||
|
{
|
||||||
|
RateLimiter::for('two-factor', function (Request $request) {
|
||||||
|
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
||||||
|
});
|
||||||
|
|
||||||
|
RateLimiter::for('login', function (Request $request) {
|
||||||
|
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
|
||||||
|
|
||||||
|
return Limit::perMinute(5)->by($throttleKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Providers/HorizonServiceProvider.php
Normal file
36
app/Providers/HorizonServiceProvider.php
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Laravel\Horizon\Horizon;
|
||||||
|
use Laravel\Horizon\HorizonApplicationServiceProvider;
|
||||||
|
|
||||||
|
class HorizonServiceProvider extends HorizonApplicationServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Bootstrap any application services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
// Horizon::routeSmsNotificationsTo('15556667777');
|
||||||
|
// Horizon::routeMailNotificationsTo('example@example.com');
|
||||||
|
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the Horizon gate.
|
||||||
|
*
|
||||||
|
* This gate determines who can access Horizon in non-local environments.
|
||||||
|
*/
|
||||||
|
protected function gate(): void
|
||||||
|
{
|
||||||
|
Gate::define('viewHorizon', function ($user = null) {
|
||||||
|
return in_array(optional($user)->email, [
|
||||||
|
//
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/Services/Social/LinkedInPagePublisher.php
Normal file
69
app/Services/Social/LinkedInPagePublisher.php
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Social;
|
||||||
|
|
||||||
|
use App\Models\PostPlatform;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class LinkedInPagePublisher
|
||||||
|
{
|
||||||
|
public function publish(PostPlatform $postPlatform): array
|
||||||
|
{
|
||||||
|
$account = $postPlatform->socialAccount;
|
||||||
|
$accessToken = $account->access_token;
|
||||||
|
|
||||||
|
$organizationId = $account->meta['organization_id'] ?? null;
|
||||||
|
|
||||||
|
if (! $organizationId) {
|
||||||
|
throw new \Exception('LinkedIn Page organization ID not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
$organizationUrn = "urn:li:organization:{$organizationId}";
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'author' => $organizationUrn,
|
||||||
|
'lifecycleState' => 'PUBLISHED',
|
||||||
|
'specificContent' => [
|
||||||
|
'com.linkedin.ugc.ShareContent' => [
|
||||||
|
'shareCommentary' => [
|
||||||
|
'text' => $postPlatform->content,
|
||||||
|
],
|
||||||
|
'shareMediaCategory' => 'NONE',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'visibility' => [
|
||||||
|
'com.linkedin.ugc.MemberNetworkVisibility' => 'PUBLIC',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$media = $postPlatform->media;
|
||||||
|
|
||||||
|
if ($media->isNotEmpty()) {
|
||||||
|
$firstMedia = $media->first();
|
||||||
|
|
||||||
|
if ($firstMedia->type->value === 'image') {
|
||||||
|
$payload['specificContent']['com.linkedin.ugc.ShareContent']['shareMediaCategory'] = 'IMAGE';
|
||||||
|
$payload['specificContent']['com.linkedin.ugc.ShareContent']['media'] = [
|
||||||
|
[
|
||||||
|
'status' => 'READY',
|
||||||
|
'originalUrl' => $firstMedia->url,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = Http::withToken($accessToken)
|
||||||
|
->post('https://api.linkedin.com/v2/ugcPosts', $payload);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
throw new \Exception("LinkedIn Page API error: " . $response->body());
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $data['id'] ?? 'unknown',
|
||||||
|
'url' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Services/Social/LinkedInPublisher.php
Normal file
63
app/Services/Social/LinkedInPublisher.php
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Social;
|
||||||
|
|
||||||
|
use App\Models\PostPlatform;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class LinkedInPublisher
|
||||||
|
{
|
||||||
|
public function publish(PostPlatform $postPlatform): array
|
||||||
|
{
|
||||||
|
$account = $postPlatform->socialAccount;
|
||||||
|
$accessToken = $account->access_token;
|
||||||
|
|
||||||
|
$personUrn = "urn:li:person:{$account->platform_user_id}";
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'author' => $personUrn,
|
||||||
|
'lifecycleState' => 'PUBLISHED',
|
||||||
|
'specificContent' => [
|
||||||
|
'com.linkedin.ugc.ShareContent' => [
|
||||||
|
'shareCommentary' => [
|
||||||
|
'text' => $postPlatform->content,
|
||||||
|
],
|
||||||
|
'shareMediaCategory' => 'NONE',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'visibility' => [
|
||||||
|
'com.linkedin.ugc.MemberNetworkVisibility' => 'PUBLIC',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$media = $postPlatform->media;
|
||||||
|
|
||||||
|
if ($media->isNotEmpty()) {
|
||||||
|
$firstMedia = $media->first();
|
||||||
|
|
||||||
|
if ($firstMedia->type->value === 'image') {
|
||||||
|
$payload['specificContent']['com.linkedin.ugc.ShareContent']['shareMediaCategory'] = 'IMAGE';
|
||||||
|
$payload['specificContent']['com.linkedin.ugc.ShareContent']['media'] = [
|
||||||
|
[
|
||||||
|
'status' => 'READY',
|
||||||
|
'originalUrl' => $firstMedia->url,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = Http::withToken($accessToken)
|
||||||
|
->post('https://api.linkedin.com/v2/ugcPosts', $payload);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
throw new \Exception("LinkedIn API error: " . $response->body());
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $data['id'] ?? 'unknown',
|
||||||
|
'url' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Services/Social/TikTokPublisher.php
Normal file
45
app/Services/Social/TikTokPublisher.php
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Social;
|
||||||
|
|
||||||
|
use App\Models\PostPlatform;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class TikTokPublisher
|
||||||
|
{
|
||||||
|
public function publish(PostPlatform $postPlatform): array
|
||||||
|
{
|
||||||
|
$account = $postPlatform->socialAccount;
|
||||||
|
$accessToken = $account->access_token;
|
||||||
|
|
||||||
|
$media = $postPlatform->media->first();
|
||||||
|
|
||||||
|
if (! $media || $media->type->value !== 'video') {
|
||||||
|
throw new \Exception('TikTok requires a video to publish.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = Http::withToken($accessToken)
|
||||||
|
->post('https://open.tiktokapis.com/v2/post/publish/inbox/video/init/', [
|
||||||
|
'source_info' => [
|
||||||
|
'source' => 'PULL_FROM_URL',
|
||||||
|
'video_url' => $media->url,
|
||||||
|
],
|
||||||
|
'post_info' => [
|
||||||
|
'title' => $postPlatform->content,
|
||||||
|
'privacy_level' => 'PUBLIC_TO_EVERYONE',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
throw new \Exception("TikTok API error: " . $response->body());
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $data['data']['publish_id'] ?? 'unknown',
|
||||||
|
'url' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Services/Social/XPublisher.php
Normal file
58
app/Services/Social/XPublisher.php
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Social;
|
||||||
|
|
||||||
|
use App\Models\PostPlatform;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class XPublisher
|
||||||
|
{
|
||||||
|
public function publish(PostPlatform $postPlatform): array
|
||||||
|
{
|
||||||
|
$account = $postPlatform->socialAccount;
|
||||||
|
$accessToken = $account->access_token;
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'text' => $postPlatform->content,
|
||||||
|
];
|
||||||
|
|
||||||
|
$media = $postPlatform->media;
|
||||||
|
|
||||||
|
if ($media->isNotEmpty()) {
|
||||||
|
$mediaIds = [];
|
||||||
|
|
||||||
|
foreach ($media as $mediaItem) {
|
||||||
|
$uploadedMediaId = $this->uploadMedia($accessToken, $mediaItem);
|
||||||
|
if ($uploadedMediaId) {
|
||||||
|
$mediaIds[] = $uploadedMediaId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($mediaIds)) {
|
||||||
|
$payload['media'] = [
|
||||||
|
'media_ids' => $mediaIds,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = Http::withToken($accessToken)
|
||||||
|
->post('https://api.twitter.com/2/tweets', $payload);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
throw new \Exception("X API error: " . $response->body());
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
$tweetId = $data['data']['id'] ?? null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $tweetId ?? 'unknown',
|
||||||
|
'url' => $tweetId ? "https://x.com/{$account->username}/status/{$tweetId}" : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function uploadMedia(string $accessToken, $mediaItem): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/Socialite/LinkedInPageExtendSocialite.php
Normal file
14
app/Socialite/LinkedInPageExtendSocialite.php
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Socialite;
|
||||||
|
|
||||||
|
use SocialiteProviders\LinkedIn\Provider;
|
||||||
|
use SocialiteProviders\Manager\SocialiteWasCalled;
|
||||||
|
|
||||||
|
class LinkedInPageExtendSocialite
|
||||||
|
{
|
||||||
|
public function handle(SocialiteWasCalled $socialiteWasCalled): void
|
||||||
|
{
|
||||||
|
$socialiteWasCalled->extendSocialite('linkedin-openid', Provider::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/helpers.php
Normal file
61
app/helpers.php
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
if (! function_exists('uploadFromUrl')) {
|
||||||
|
/**
|
||||||
|
* Download an image from URL and upload to storage.
|
||||||
|
*
|
||||||
|
* @param string|null $url The URL to download from
|
||||||
|
* @param string $directory The directory to store the file in
|
||||||
|
* @return string|null The stored file path or null on failure
|
||||||
|
*/
|
||||||
|
function uploadFromUrl(?string $url, string $directory = 'social-accounts'): ?string
|
||||||
|
{
|
||||||
|
if (! $url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::timeout(10)->get($url);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
Log::warning('uploadFromUrl: Failed to download', [
|
||||||
|
'url' => $url,
|
||||||
|
'status' => $response->status(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentType = $response->header('Content-Type') ?? 'image/jpeg';
|
||||||
|
$extension = match (true) {
|
||||||
|
str_contains($contentType, 'png') => 'png',
|
||||||
|
str_contains($contentType, 'gif') => 'gif',
|
||||||
|
str_contains($contentType, 'webp') => 'webp',
|
||||||
|
default => 'jpg',
|
||||||
|
};
|
||||||
|
|
||||||
|
$filename = sprintf(
|
||||||
|
'%s/%s.%s',
|
||||||
|
trim($directory, '/'),
|
||||||
|
Str::uuid(),
|
||||||
|
$extension
|
||||||
|
);
|
||||||
|
|
||||||
|
Storage::put($filename, $response->body(), 'public');
|
||||||
|
|
||||||
|
return $filename;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::warning('uploadFromUrl: Exception', [
|
||||||
|
'url' => $url,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
artisan
Executable file
18
artisan
Executable 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
boost.json
Normal file
13
boost.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"agents": [
|
||||||
|
"claude_code",
|
||||||
|
"gemini"
|
||||||
|
],
|
||||||
|
"editors": [
|
||||||
|
"claude_code"
|
||||||
|
],
|
||||||
|
"guidelines": [
|
||||||
|
"laravel/fortify"
|
||||||
|
],
|
||||||
|
"herd_mcp": true
|
||||||
|
}
|
||||||
27
bootstrap/app.php
Normal file
27
bootstrap/app.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\HandleAppearance;
|
||||||
|
use App\Http\Middleware\HandleInertiaRequests;
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
|
||||||
|
|
||||||
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
|
->withRouting(
|
||||||
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
health: '/up',
|
||||||
|
)
|
||||||
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||||
|
|
||||||
|
$middleware->web(append: [
|
||||||
|
HandleAppearance::class,
|
||||||
|
HandleInertiaRequests::class,
|
||||||
|
AddLinkHeadersForPreloadedAssets::class,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
|
//
|
||||||
|
})->create();
|
||||||
2
bootstrap/cache/.gitignore
vendored
Normal file
2
bootstrap/cache/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
7
bootstrap/providers.php
Normal file
7
bootstrap/providers.php
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
App\Providers\AppServiceProvider::class,
|
||||||
|
App\Providers\FortifyServiceProvider::class,
|
||||||
|
App\Providers\HorizonServiceProvider::class,
|
||||||
|
];
|
||||||
19
components.json
Normal file
19
components.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://shadcn-vue.com/schema.json",
|
||||||
|
"style": "new-york-v4",
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "resources/css/app.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"composables": "@/composables",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
118
composer.json
Normal file
118
composer.json
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://getcomposer.org/schema.json",
|
||||||
|
"name": "laravel/vue-starter-kit",
|
||||||
|
"type": "project",
|
||||||
|
"description": "The skeleton application for the Laravel framework.",
|
||||||
|
"keywords": [
|
||||||
|
"laravel",
|
||||||
|
"framework"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"require": {
|
||||||
|
"php": "^8.2",
|
||||||
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
|
"laravel/cashier": "^16.2",
|
||||||
|
"laravel/fortify": "^1.30",
|
||||||
|
"laravel/framework": "^12.0",
|
||||||
|
"laravel/horizon": "^5.42",
|
||||||
|
"laravel/socialite": "^5.24",
|
||||||
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"laravel/wayfinder": "^0.1.9",
|
||||||
|
"league/flysystem-aws-s3-v3": "^3.0",
|
||||||
|
"predis/predis": "^3.3",
|
||||||
|
"socialiteproviders/linkedin": "^5.0",
|
||||||
|
"socialiteproviders/tiktok": "^5.2",
|
||||||
|
"socialiteproviders/twitter": "^4.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"fakerphp/faker": "^1.23",
|
||||||
|
"laravel/boost": "^1.8",
|
||||||
|
"laravel/pail": "^1.2.2",
|
||||||
|
"laravel/pint": "^1.24",
|
||||||
|
"laravel/sail": "^1.41",
|
||||||
|
"mockery/mockery": "^1.6",
|
||||||
|
"nunomaduro/collision": "^8.6",
|
||||||
|
"pestphp/pest": "^4.3",
|
||||||
|
"pestphp/pest-plugin-laravel": "^4.0"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"app/helpers.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "app/",
|
||||||
|
"Database\\Factories\\": "database/factories/",
|
||||||
|
"Database\\Seeders\\": "database/seeders/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||||
|
],
|
||||||
|
"dev:ssr": [
|
||||||
|
"npm run build:ssr",
|
||||||
|
"Composer\\Config::disableProcessTimeout",
|
||||||
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
|
||||||
|
],
|
||||||
|
"lint": [
|
||||||
|
"pint --parallel"
|
||||||
|
],
|
||||||
|
"test:lint": [
|
||||||
|
"pint --parallel --test"
|
||||||
|
],
|
||||||
|
"test": [
|
||||||
|
"@php artisan config:clear --ansi",
|
||||||
|
"@test:lint",
|
||||||
|
"@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",
|
||||||
|
"@php artisan boost:update --ansi"
|
||||||
|
],
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true
|
||||||
|
}
|
||||||
11371
composer.lock
generated
Normal file
11371
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
126
config/app.php
Normal file
126
config/app.php
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
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' => 'UTC',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| 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', 'en'),
|
||||||
|
|
||||||
|
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||||
|
|
||||||
|
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| 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(',', (string) 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'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
115
config/auth.php
Normal file
115
config/auth.php
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
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' => [
|
||||||
|
'web' => [
|
||||||
|
'driver' => 'session',
|
||||||
|
'provider' => 'users',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| 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', App\Models\User::class),
|
||||||
|
],
|
||||||
|
|
||||||
|
// 'users' => [
|
||||||
|
// 'driver' => 'database',
|
||||||
|
// 'table' => 'users',
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| 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 number 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),
|
||||||
|
|
||||||
|
];
|
||||||
117
config/cache.php
Normal file
117
config/cache.php
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
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' => [
|
||||||
|
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||||
|
],
|
||||||
|
'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-'),
|
||||||
|
|
||||||
|
];
|
||||||
127
config/cashier.php
Normal file
127
config/cashier.php
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Laravel\Cashier\Console\WebhookCommand;
|
||||||
|
use Laravel\Cashier\Invoices\DompdfInvoiceRenderer;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Stripe Keys
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The Stripe publishable key and secret key give you access to Stripe's
|
||||||
|
| API. The "publishable" key is typically used when interacting with
|
||||||
|
| Stripe.js while the "secret" key accesses private API endpoints.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'key' => env('STRIPE_KEY'),
|
||||||
|
|
||||||
|
'secret' => env('STRIPE_SECRET'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cashier Path
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This is the base URI path where Cashier's views, such as the payment
|
||||||
|
| verification screen, will be available from. You're free to tweak
|
||||||
|
| this path according to your preferences and application design.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'path' => env('CASHIER_PATH', 'stripe'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Stripe Webhooks
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Your Stripe webhook secret is used to prevent unauthorized requests to
|
||||||
|
| your Stripe webhook handling controllers. The tolerance setting will
|
||||||
|
| check the drift between the current time and the signed request's.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'webhook' => [
|
||||||
|
'secret' => env('STRIPE_WEBHOOK_SECRET'),
|
||||||
|
'tolerance' => env('STRIPE_WEBHOOK_TOLERANCE', 300),
|
||||||
|
'events' => WebhookCommand::DEFAULT_EVENTS,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Currency
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This is the default currency that will be used when generating charges
|
||||||
|
| from your application. Of course, you are welcome to use any of the
|
||||||
|
| various world currencies that are currently supported via Stripe.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'currency' => env('CASHIER_CURRENCY', 'usd'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Currency Locale
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This is the default locale in which your money values are formatted in
|
||||||
|
| for display. To utilize other locales besides the default en locale
|
||||||
|
| verify you have the "intl" PHP extension installed on the system.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'currency_locale' => env('CASHIER_CURRENCY_LOCALE', 'en'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Payment Confirmation Notification
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| If this setting is enabled, Cashier will automatically notify customers
|
||||||
|
| whose payments require additional verification. You should listen to
|
||||||
|
| Stripe's webhooks in order for this feature to function correctly.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'payment_notification' => env('CASHIER_PAYMENT_NOTIFICATION'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Invoice Settings
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following options determine how Cashier invoices are converted from
|
||||||
|
| HTML into PDFs. You're free to change the options based on the needs
|
||||||
|
| of your application or your preferences regarding invoice styling.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'invoices' => [
|
||||||
|
'renderer' => env('CASHIER_INVOICE_RENDERER', DompdfInvoiceRenderer::class),
|
||||||
|
|
||||||
|
'options' => [
|
||||||
|
// Supported: 'letter', 'legal', 'A4'
|
||||||
|
'paper' => env('CASHIER_PAPER', 'letter'),
|
||||||
|
|
||||||
|
'remote_enabled' => env('CASHIER_REMOTE_ENABLED', false),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Stripe Logger
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This setting defines which logging channel will be used by the Stripe
|
||||||
|
| library to write log messages. You are free to specify any of your
|
||||||
|
| logging channels listed inside the "logging" configuration file.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'logger' => env('CASHIER_LOGGER'),
|
||||||
|
|
||||||
|
];
|
||||||
183
config/database.php
Normal file
183
config/database.php
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
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 ? \Pdo\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 ? \Pdo\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,
|
||||||
|
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||||
|
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| 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', 'predis'),
|
||||||
|
|
||||||
|
'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),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
92
config/filesystems.php
Normal file
92
config/filesystems.php
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
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', 'r2'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| 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/private'),
|
||||||
|
'serve' => true,
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'public' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/public'),
|
||||||
|
'url' => rtrim(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,
|
||||||
|
],
|
||||||
|
|
||||||
|
'r2' => [
|
||||||
|
'driver' => 's3',
|
||||||
|
'key' => env('R2_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('R2_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('R2_REGION', 'auto'),
|
||||||
|
'bucket' => env('R2_BUCKET'),
|
||||||
|
'url' => env('R2_URL'),
|
||||||
|
'endpoint' => env('R2_ENDPOINT'),
|
||||||
|
'use_path_style_endpoint' => true,
|
||||||
|
'throw' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| 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'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
157
config/fortify.php
Normal file
157
config/fortify.php
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Laravel\Fortify\Features;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Fortify Guard
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which authentication guard Fortify will use while
|
||||||
|
| authenticating users. This value should correspond with one of your
|
||||||
|
| guards that is already present in your "auth" configuration file.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'guard' => 'web',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Fortify Password Broker
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which password broker Fortify can use when a user
|
||||||
|
| is resetting their password. This configured value should match one
|
||||||
|
| of your password brokers setup in your "auth" configuration file.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'passwords' => 'users',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Username / Email
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value defines which model attribute should be considered as your
|
||||||
|
| application's "username" field. Typically, this might be the email
|
||||||
|
| address of the users but you are free to change this value here.
|
||||||
|
|
|
||||||
|
| Out of the box, Fortify expects forgot password and reset password
|
||||||
|
| requests to have a field named 'email'. If the application uses
|
||||||
|
| another name for the field you may define it below as needed.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'username' => 'email',
|
||||||
|
|
||||||
|
'email' => 'email',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Lowercase Usernames
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value defines whether usernames should be lowercased before saving
|
||||||
|
| them in the database, as some database system string fields are case
|
||||||
|
| sensitive. You may disable this for your application if necessary.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'lowercase_usernames' => true,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Home Path
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the path where users will get redirected during
|
||||||
|
| authentication or password reset when the operations are successful
|
||||||
|
| and the user is authenticated. You are free to change this value.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'home' => '/dashboard',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Fortify Routes Prefix / Subdomain
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which prefix Fortify will assign to all the routes
|
||||||
|
| that it registers with the application. If necessary, you may change
|
||||||
|
| subdomain under which all of the Fortify routes will be available.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'prefix' => '',
|
||||||
|
|
||||||
|
'domain' => null,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Fortify Routes Middleware
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which middleware Fortify will assign to the routes
|
||||||
|
| that it registers with the application. If necessary, you may change
|
||||||
|
| these middleware but typically this provided default is preferred.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'middleware' => ['web'],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Rate Limiting
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| By default, Fortify will throttle logins to five requests per minute for
|
||||||
|
| every email and IP address combination. However, if you would like to
|
||||||
|
| specify a custom rate limiter to call then you may specify it here.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'limiters' => [
|
||||||
|
'login' => 'login',
|
||||||
|
'two-factor' => 'two-factor',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Register View Routes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify if the routes returning views should be disabled as
|
||||||
|
| you may not need them when building your own application. This may be
|
||||||
|
| especially true if you're writing a custom single-page application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'views' => true,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Features
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Some of the Fortify features are optional. You may disable the features
|
||||||
|
| by removing them from this array. You're free to only remove some of
|
||||||
|
| these features, or you can even remove all of these if you need to.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'features' => [
|
||||||
|
Features::registration(),
|
||||||
|
Features::resetPasswords(),
|
||||||
|
Features::emailVerification(),
|
||||||
|
Features::twoFactorAuthentication([
|
||||||
|
'confirm' => true,
|
||||||
|
'confirmPassword' => true,
|
||||||
|
// 'window' => 0
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
230
config/horizon.php
Normal file
230
config/horizon.php
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Horizon Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This name appears in notifications and in the Horizon UI. Unique names
|
||||||
|
| can be useful while running multiple instances of Horizon within an
|
||||||
|
| application, allowing you to identify the Horizon you're viewing.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'name' => env('HORIZON_NAME'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Horizon Domain
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This is the subdomain where Horizon will be accessible from. If this
|
||||||
|
| setting is null, Horizon will reside under the same domain as the
|
||||||
|
| application. Otherwise, this value will serve as the subdomain.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'domain' => env('HORIZON_DOMAIN'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Horizon Path
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This is the URI path where Horizon 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('HORIZON_PATH', 'horizon'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Horizon Redis Connection
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This is the name of the Redis connection where Horizon will store the
|
||||||
|
| meta information required for it to function. It includes the list
|
||||||
|
| of supervisors, failed jobs, job metrics, and other information.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use' => 'default',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Horizon Redis Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This prefix will be used when storing all Horizon data in Redis. You
|
||||||
|
| may modify the prefix when you are running multiple installations
|
||||||
|
| of Horizon on the same server so that they don't have problems.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'prefix' => env(
|
||||||
|
'HORIZON_PREFIX',
|
||||||
|
Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:'
|
||||||
|
),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Horizon Route Middleware
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These middleware will get attached onto each Horizon 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' => ['web'],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Queue Wait Time Thresholds
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option allows you to configure when the LongWaitDetected event
|
||||||
|
| will be fired. Every connection / queue combination may have its
|
||||||
|
| own, unique threshold (in seconds) before this event is fired.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'waits' => [
|
||||||
|
'redis:default' => 60,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Job Trimming Times
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you can configure for how long (in minutes) you desire Horizon to
|
||||||
|
| persist the recent and failed jobs. Typically, recent jobs are kept
|
||||||
|
| for one hour while all failed jobs are stored for an entire week.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'trim' => [
|
||||||
|
'recent' => 60,
|
||||||
|
'pending' => 60,
|
||||||
|
'completed' => 60,
|
||||||
|
'recent_failed' => 10080,
|
||||||
|
'failed' => 10080,
|
||||||
|
'monitored' => 10080,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Silenced Jobs
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Silencing a job will instruct Horizon to not place the job in the list
|
||||||
|
| of completed jobs within the Horizon dashboard. This setting may be
|
||||||
|
| used to fully remove any noisy jobs from the completed jobs list.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'silenced' => [
|
||||||
|
// App\Jobs\ExampleJob::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
'silenced_tags' => [
|
||||||
|
// 'notifications',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Metrics
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you can configure how many snapshots should be kept to display in
|
||||||
|
| the metrics graph. This will get used in combination with Horizon's
|
||||||
|
| `horizon:snapshot` schedule to define how long to retain metrics.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'metrics' => [
|
||||||
|
'trim_snapshots' => [
|
||||||
|
'job' => 24,
|
||||||
|
'queue' => 24,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Fast Termination
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When this option is enabled, Horizon's "terminate" command will not
|
||||||
|
| wait on all of the workers to terminate unless the --wait option
|
||||||
|
| is provided. Fast termination can shorten deployment delay by
|
||||||
|
| allowing a new instance of Horizon to start while the last
|
||||||
|
| instance will continue to terminate each of its workers.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'fast_termination' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Memory Limit (MB)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value describes the maximum amount of memory the Horizon master
|
||||||
|
| supervisor may consume before it is terminated and restarted. For
|
||||||
|
| configuring these limits on your workers, see the next section.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'memory_limit' => 64,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Queue Worker Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define the queue worker settings used by your application
|
||||||
|
| in all environments. These supervisors and settings handle all your
|
||||||
|
| queued jobs and will be provisioned by Horizon during deployment.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'defaults' => [
|
||||||
|
'supervisor-1' => [
|
||||||
|
'connection' => 'redis',
|
||||||
|
'queue' => ['default'],
|
||||||
|
'balance' => 'auto',
|
||||||
|
'autoScalingStrategy' => 'time',
|
||||||
|
'maxProcesses' => 1,
|
||||||
|
'maxTime' => 0,
|
||||||
|
'maxJobs' => 0,
|
||||||
|
'memory' => 128,
|
||||||
|
'tries' => 1,
|
||||||
|
'timeout' => 60,
|
||||||
|
'nice' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'environments' => [
|
||||||
|
'production' => [
|
||||||
|
'supervisor-1' => [
|
||||||
|
'maxProcesses' => 10,
|
||||||
|
'balanceMaxShift' => 1,
|
||||||
|
'balanceCooldown' => 3,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'local' => [
|
||||||
|
'supervisor-1' => [
|
||||||
|
'maxProcesses' => 3,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
52
config/inertia.php
Normal file
52
config/inertia.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Server Side Rendering
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These options configure if and how Inertia uses Server Side Rendering
|
||||||
|
| to pre-render every initial visit made to your application's pages
|
||||||
|
| automatically. A separate rendering service should be available.
|
||||||
|
|
|
||||||
|
| See: https://inertiajs.com/server-side-rendering
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'ssr' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'url' => 'http://127.0.0.1:13714',
|
||||||
|
// 'bundle' => base_path('bootstrap/ssr/ssr.mjs'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| 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 the paths.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'testing' => [
|
||||||
|
'ensure_pages_exist' => true,
|
||||||
|
|
||||||
|
'page_paths' => [
|
||||||
|
resource_path('js/pages'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'page_extensions' => [
|
||||||
|
'js',
|
||||||
|
'jsx',
|
||||||
|
'svelte',
|
||||||
|
'ts',
|
||||||
|
'tsx',
|
||||||
|
'vue',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
132
config/logging.php
Normal file
132
config/logging.php
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
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'),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
118
config/mail.php
Normal file
118
config/mail.php
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
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', 'log'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| 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',
|
||||||
|
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
||||||
|
// 'client' => [
|
||||||
|
// 'timeout' => 5,
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
|
||||||
|
'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'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
129
config/queue.php
Normal file
129
config/queue.php
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
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',
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
68
config/services.php
Normal file
68
config/services.php
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
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'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'linkedin' => [
|
||||||
|
'client_id' => env('LINKEDIN_CLIENT_ID'),
|
||||||
|
'client_secret' => env('LINKEDIN_CLIENT_SECRET'),
|
||||||
|
'redirect' => env('LINKEDIN_CLIENT_REDIRECT'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'linkedin-openid' => [
|
||||||
|
'client_id' => env('LINKEDIN_CLIENT_ID'),
|
||||||
|
'client_secret' => env('LINKEDIN_CLIENT_SECRET'),
|
||||||
|
'redirect' => env('LINKEDIN_CLIENT_REDIRECT'),
|
||||||
|
'redirect_page' => env('LINKEDIN_PAGE_CLIENT_REDIRECT'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'twitter' => [
|
||||||
|
'client_id' => env('X_CLIENT_ID'),
|
||||||
|
'client_secret' => env('X_CLIENT_SECRET'),
|
||||||
|
'redirect' => env('X_CLIENT_REDIRECT'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'tiktok' => [
|
||||||
|
'client_id' => env('TIKTOK_CLIENT_ID'),
|
||||||
|
'client_secret' => env('TIKTOK_CLIENT_SECRET'),
|
||||||
|
'redirect' => env('TIKTOK_CLIENT_REDIRECT'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'stripe' => [
|
||||||
|
'price_id' => env('STRIPE_PRICE_ID'),
|
||||||
|
'workspace_price' => env('STRIPE_WORKSPACE_PRICE', 2000), // $20.00 in cents
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
217
config/session.php
Normal file
217
config/session.php
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
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),
|
||||||
|
|
||||||
|
];
|
||||||
1
database/.gitignore
vendored
Normal file
1
database/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
*.sqlite*
|
||||||
23
database/factories/PostFactory.php
Normal file
23
database/factories/PostFactory.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Post>
|
||||||
|
*/
|
||||||
|
class PostFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
23
database/factories/PostMediaFactory.php
Normal file
23
database/factories/PostMediaFactory.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\PostMedia>
|
||||||
|
*/
|
||||||
|
class PostMediaFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
23
database/factories/PostPlatformFactory.php
Normal file
23
database/factories/PostPlatformFactory.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\PostPlatform>
|
||||||
|
*/
|
||||||
|
class PostPlatformFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
23
database/factories/SocialAccountFactory.php
Normal file
23
database/factories/SocialAccountFactory.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\SocialAccount>
|
||||||
|
*/
|
||||||
|
class SocialAccountFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
59
database/factories/UserFactory.php
Normal file
59
database/factories/UserFactory.php
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
|
||||||
|
*/
|
||||||
|
class UserFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The current password being used by the factory.
|
||||||
|
*/
|
||||||
|
protected static ?string $password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => fake()->name(),
|
||||||
|
'email' => fake()->unique()->safeEmail(),
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'password' => static::$password ??= Hash::make('password'),
|
||||||
|
'remember_token' => Str::random(10),
|
||||||
|
'two_factor_secret' => null,
|
||||||
|
'two_factor_recovery_codes' => null,
|
||||||
|
'two_factor_confirmed_at' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicate that the model's email address should be unverified.
|
||||||
|
*/
|
||||||
|
public function unverified(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'email_verified_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicate that the model has two-factor authentication configured.
|
||||||
|
*/
|
||||||
|
public function withTwoFactor(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'two_factor_secret' => encrypt('secret'),
|
||||||
|
'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code-1'])),
|
||||||
|
'two_factor_confirmed_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
database/factories/WorkspaceFactory.php
Normal file
23
database/factories/WorkspaceFactory.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Workspace>
|
||||||
|
*/
|
||||||
|
class WorkspaceFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
57
database/factories/WorkspaceInviteFactory.php
Normal file
57
database/factories/WorkspaceInviteFactory.php
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Enums\InviteStatus;
|
||||||
|
use App\Enums\WorkspaceRole;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WorkspaceInvite>
|
||||||
|
*/
|
||||||
|
class WorkspaceInviteFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'workspace_id' => Workspace::factory(),
|
||||||
|
'invited_by' => User::factory(),
|
||||||
|
'email' => fake()->unique()->safeEmail(),
|
||||||
|
'token' => Str::random(64),
|
||||||
|
'role' => WorkspaceRole::Member,
|
||||||
|
'status' => InviteStatus::Pending,
|
||||||
|
'expires_at' => now()->addDays(7),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expired(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'expires_at' => now()->subDay(),
|
||||||
|
'status' => InviteStatus::Expired,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function accepted(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'status' => InviteStatus::Accepted,
|
||||||
|
'accepted_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelled(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'status' => InviteStatus::Cancelled,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
52
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('users', function (Blueprint $table) {
|
||||||
|
$table->uuid('id')->primary();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('email')->unique();
|
||||||
|
$table->timestamp('email_verified_at')->nullable();
|
||||||
|
$table->string('password');
|
||||||
|
$table->text('two_factor_secret')->nullable();
|
||||||
|
$table->text('two_factor_recovery_codes')->nullable();
|
||||||
|
$table->timestamp('two_factor_confirmed_at')->nullable();
|
||||||
|
$table->rememberToken();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||||
|
$table->string('email')->primary();
|
||||||
|
$table->string('token');
|
||||||
|
$table->timestamp('created_at')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('sessions', function (Blueprint $table) {
|
||||||
|
$table->string('id')->primary();
|
||||||
|
$table->uuid('user_id')->nullable()->index();
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->text('user_agent')->nullable();
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->integer('last_activity')->index();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('users');
|
||||||
|
Schema::dropIfExists('password_reset_tokens');
|
||||||
|
Schema::dropIfExists('sessions');
|
||||||
|
}
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue