v1.0.0
This commit is contained in:
18
src/.editorconfig
Normal file
18
src/.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,docker-compose}.{yml,yaml}]
|
||||
indent_size = 4
|
||||
68
src/.env.example
Normal file
68
src/.env.example
Normal file
@@ -0,0 +1,68 @@
|
||||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
|
||||
VERSION=1.0.0
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=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
src/.gitattributes
vendored
Normal file
11
src/.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
|
||||
|
||||
/.github export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
.styleci.yml export-ignore
|
||||
31
src/.gitignore
vendored
Normal file
31
src/.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.codex
|
||||
/.cursor/
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/.scribe
|
||||
/public/build
|
||||
/public/fonts-manifest.dev.json
|
||||
/public/hot
|
||||
/public/storage
|
||||
/public/vendor/scribe
|
||||
/resources/views/scribe
|
||||
/storage/app/private/scribe
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
_ide_helper.php
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
2
src/.npmrc
Normal file
2
src/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
ignore-scripts=true
|
||||
audit=true
|
||||
58
src/README.md
Normal file
58
src/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
|
||||
|
||||
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
|
||||
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
|
||||
|
||||
## Agentic Development
|
||||
|
||||
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
|
||||
|
||||
```bash
|
||||
composer require laravel/boost --dev
|
||||
|
||||
php artisan boost:install
|
||||
```
|
||||
|
||||
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
42
src/app/Console/Commands/CheckSlaBreaches.php
Normal file
42
src/app/Console/Commands/CheckSlaBreaches.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Models\Status;
|
||||
use App\Models\Ticket;
|
||||
use App\Services\TicketService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CheckSlaBreaches extends Command
|
||||
{
|
||||
protected $signature = 'tickets:check-sla-breaches';
|
||||
|
||||
protected $description = 'Notify the assigned operator once for every open ticket whose SLA resolution deadline has passed';
|
||||
|
||||
public function handle(TicketService $tickets): int
|
||||
{
|
||||
$setting = NotificationSetting::query()->where('trigger_key', 'sla_breached')->first();
|
||||
|
||||
if (! $setting || ! $setting->enabled || ! $setting->email_template_id) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$overdue = Ticket::query()
|
||||
->whereNotIn('status_key', Status::closedKeys())
|
||||
->whereNotNull('assignee_id')
|
||||
->whereNull('sla_notified_at')
|
||||
->with(['priority.slaRule', 'assignee'])
|
||||
->get()
|
||||
->filter(fn (Ticket $ticket) => $ticket->isOverdue());
|
||||
|
||||
foreach ($overdue as $ticket) {
|
||||
$tickets->notify($ticket, 'sla_breached');
|
||||
$ticket->update(['sla_notified_at' => now()]);
|
||||
}
|
||||
|
||||
$this->info("Checked SLA breaches: notified {$overdue->count()} ticket(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
37
src/app/Http/Controllers/Api/CategoryController.php
Normal file
37
src/app/Http/Controllers/Api/CategoryController.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\CategoryResource;
|
||||
use App\Models\Category;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
#[OA\Get(
|
||||
path: '/categories',
|
||||
summary: 'List categories',
|
||||
description: "Returns every category with its subcategories and each subcategory's custom fields.",
|
||||
security: [['sanctum' => []]],
|
||||
tags: ['Dictionaries'],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'OK',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property('data', type: 'array', items: new OA\Items(ref: '#/components/schemas/Category')),
|
||||
],
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 403, description: 'Missing the dictionaries:read ability'),
|
||||
],
|
||||
)]
|
||||
public function index()
|
||||
{
|
||||
return CategoryResource::collection(
|
||||
Category::query()->with('subcategories.customFields')->get()
|
||||
);
|
||||
}
|
||||
}
|
||||
34
src/app/Http/Controllers/Api/PriorityController.php
Normal file
34
src/app/Http/Controllers/Api/PriorityController.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\PriorityResource;
|
||||
use App\Models\Priority;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class PriorityController extends Controller
|
||||
{
|
||||
#[OA\Get(
|
||||
path: '/priorities',
|
||||
summary: 'List priorities',
|
||||
security: [['sanctum' => []]],
|
||||
tags: ['Dictionaries'],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'OK',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property('data', type: 'array', items: new OA\Items(ref: '#/components/schemas/Priority')),
|
||||
],
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 403, description: 'Missing the dictionaries:read ability'),
|
||||
],
|
||||
)]
|
||||
public function index()
|
||||
{
|
||||
return PriorityResource::collection(Priority::query()->orderBy('sort_order')->get());
|
||||
}
|
||||
}
|
||||
34
src/app/Http/Controllers/Api/StatusController.php
Normal file
34
src/app/Http/Controllers/Api/StatusController.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\StatusResource;
|
||||
use App\Models\Status;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class StatusController extends Controller
|
||||
{
|
||||
#[OA\Get(
|
||||
path: '/statuses',
|
||||
summary: 'List statuses',
|
||||
security: [['sanctum' => []]],
|
||||
tags: ['Dictionaries'],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'OK',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property('data', type: 'array', items: new OA\Items(ref: '#/components/schemas/Status')),
|
||||
],
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 403, description: 'Missing the dictionaries:read ability'),
|
||||
],
|
||||
)]
|
||||
public function index()
|
||||
{
|
||||
return StatusResource::collection(Status::query()->orderBy('sort_order')->get());
|
||||
}
|
||||
}
|
||||
34
src/app/Http/Controllers/Api/TeamController.php
Normal file
34
src/app/Http/Controllers/Api/TeamController.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\TeamResource;
|
||||
use App\Models\Team;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class TeamController extends Controller
|
||||
{
|
||||
#[OA\Get(
|
||||
path: '/teams',
|
||||
summary: 'List teams',
|
||||
security: [['sanctum' => []]],
|
||||
tags: ['Dictionaries'],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'OK',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property('data', type: 'array', items: new OA\Items(ref: '#/components/schemas/Team')),
|
||||
],
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 403, description: 'Missing the dictionaries:read ability'),
|
||||
],
|
||||
)]
|
||||
public function index()
|
||||
{
|
||||
return TeamResource::collection(Team::query()->orderBy('name')->get());
|
||||
}
|
||||
}
|
||||
215
src/app/Http/Controllers/Api/TicketController.php
Normal file
215
src/app/Http/Controllers/Api/TicketController.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\StoreTicketRequest;
|
||||
use App\Http\Requests\Api\UpdateTicketRequest;
|
||||
use App\Http\Resources\TicketResource;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Services\TicketService;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class TicketController extends Controller
|
||||
{
|
||||
public function __construct(protected TicketService $tickets) {}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/tickets',
|
||||
summary: 'List tickets',
|
||||
description: "Any filter below accepts a `!` prefix to negate it, e.g. `status_key=!closed` returns everything except closed tickets.",
|
||||
security: [['sanctum' => []]],
|
||||
tags: ['Tickets'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'status_key', description: 'Filter by status key. Prefix with `!` to exclude, e.g. `!closed`.', in: 'query', schema: new OA\Schema(type: 'string'), example: '!closed'),
|
||||
new OA\Parameter(name: 'priority_key', description: 'Filter by priority key. Prefix with `!` to exclude.', in: 'query', schema: new OA\Schema(type: 'string'), example: 'high'),
|
||||
new OA\Parameter(name: 'team_id', description: 'Filter by team id. Prefix with `!` to exclude.', in: 'query', schema: new OA\Schema(type: 'integer'), example: 1),
|
||||
new OA\Parameter(name: 'assignee_id', description: 'Filter by assignee (operator) user id. Prefix with `!` to exclude.', in: 'query', schema: new OA\Schema(type: 'integer'), example: 3),
|
||||
new OA\Parameter(name: 'customer_id', description: 'Filter by customer user id. Prefix with `!` to exclude.', in: 'query', schema: new OA\Schema(type: 'integer'), example: 5),
|
||||
new OA\Parameter(name: 'subcategory_id', description: 'Filter by subcategory id. Prefix with `!` to exclude.', in: 'query', schema: new OA\Schema(type: 'integer'), example: 2),
|
||||
new OA\Parameter(name: 'per_page', description: 'Results per page (max 100).', in: 'query', schema: new OA\Schema(type: 'integer'), example: 25),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'OK',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property('data', type: 'array', items: new OA\Items(ref: '#/components/schemas/Ticket')),
|
||||
new OA\Property('links', properties: [
|
||||
new OA\Property('first', type: 'string', example: '/api/v1/tickets?page=1'),
|
||||
new OA\Property('last', type: 'string', example: '/api/v1/tickets?page=1'),
|
||||
], type: 'object'),
|
||||
new OA\Property('meta', properties: [
|
||||
new OA\Property('current_page', type: 'integer', example: 1),
|
||||
new OA\Property('per_page', type: 'integer', example: 25),
|
||||
new OA\Property('total', type: 'integer', example: 1),
|
||||
], type: 'object'),
|
||||
],
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 403, description: 'Missing the tickets:read ability'),
|
||||
],
|
||||
)]
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Ticket::query()->with(['subcategory.category', 'subcategory.customFields']);
|
||||
|
||||
foreach (['status_key', 'priority_key', 'team_id', 'assignee_id', 'customer_id', 'subcategory_id'] as $filter) {
|
||||
if ($request->filled($filter)) {
|
||||
$value = (string) $request->input($filter);
|
||||
|
||||
if (str_starts_with($value, '!')) {
|
||||
$query->where($filter, '!=', substr($value, 1));
|
||||
} else {
|
||||
$query->where($filter, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$perPage = min((int) $request->input('per_page', 25) ?: 25, 100);
|
||||
|
||||
return TicketResource::collection($query->orderByDesc('created_at')->paginate($perPage));
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/tickets/{ticket}',
|
||||
summary: 'Get a ticket',
|
||||
security: [['sanctum' => []]],
|
||||
tags: ['Tickets'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'ticket', description: 'Ticket id.', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'OK',
|
||||
content: new OA\JsonContent(properties: [new OA\Property('data', ref: '#/components/schemas/Ticket')]),
|
||||
),
|
||||
new OA\Response(response: 403, description: 'Missing the tickets:read ability'),
|
||||
new OA\Response(response: 404, description: 'Ticket not found'),
|
||||
],
|
||||
)]
|
||||
public function show(Ticket $ticket)
|
||||
{
|
||||
return new TicketResource($ticket->load(['subcategory.category', 'subcategory.customFields']));
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/tickets',
|
||||
summary: 'Create a ticket',
|
||||
description: 'Behaves like a guest ticket submission: no customer account is required, the given email/name identify the reporter (an existing user is matched/created automatically when LDAP guest auto-provisioning is enabled).',
|
||||
security: [['sanctum' => []]],
|
||||
tags: ['Tickets'],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'multipart/form-data',
|
||||
schema: new OA\Schema(
|
||||
required: ['email', 'name', 'subject', 'body'],
|
||||
properties: [
|
||||
new OA\Property('email', type: 'string', format: 'email', description: 'Reporter email address.', example: 'jan.kowalski@firma.pl'),
|
||||
new OA\Property('name', type: 'string', description: 'Reporter display name.', example: 'Jan Kowalski'),
|
||||
new OA\Property('subcategory_id', type: 'integer', nullable: true, description: 'The subcategory to file the ticket under.'),
|
||||
new OA\Property('subject', type: 'string', description: 'Ticket subject.'),
|
||||
new OA\Property('body', type: 'string', description: 'Ticket body.'),
|
||||
new OA\Property('custom_values', type: 'object', description: 'Custom field values keyed by field id.'),
|
||||
new OA\Property('attachments', type: 'array', items: new OA\Items(type: 'string', format: 'binary'), description: 'Files to attach to the opening message.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Created',
|
||||
content: new OA\JsonContent(properties: [new OA\Property('data', ref: '#/components/schemas/Ticket')]),
|
||||
),
|
||||
new OA\Response(response: 403, description: 'Missing the tickets:write ability'),
|
||||
new OA\Response(response: 422, description: 'Validation error'),
|
||||
],
|
||||
)]
|
||||
public function store(StoreTicketRequest $request)
|
||||
{
|
||||
$ticket = $this->tickets->create($request->validated(), customer: null, authorName: $request->validated('name'));
|
||||
|
||||
$apiClient = $request->user();
|
||||
$ticket->update(['api_client_id' => $apiClient->id]);
|
||||
$ticket->messages()->first()?->update(['api_client_id' => $apiClient->id]);
|
||||
|
||||
$this->tickets->attachFiles($ticket, $ticket->messages()->first(), $request->file('attachments', []));
|
||||
|
||||
return (new TicketResource($ticket->fresh()->load(['subcategory.category', 'subcategory.customFields'])))
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
path: '/tickets/{ticket}',
|
||||
summary: 'Update a ticket',
|
||||
description: 'Every field is optional — only the fields present in the request are changed.',
|
||||
security: [['sanctum' => []]],
|
||||
tags: ['Tickets'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'ticket', description: 'Ticket id.', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property('status_key', type: 'string', description: 'New status key.', example: 'in_progress'),
|
||||
new OA\Property('priority_key', type: 'string', description: 'New priority key.', example: 'high'),
|
||||
new OA\Property('assignee_id', type: 'integer', nullable: true, description: 'New assignee (operator) user id, or null to unassign.'),
|
||||
new OA\Property('team_id', type: 'integer', nullable: true, description: 'New team id, or null to clear.'),
|
||||
new OA\Property('subject', type: 'string', description: 'New subject (details update).'),
|
||||
new OA\Property('body', type: 'string', description: 'New body (details update).'),
|
||||
new OA\Property('subcategory_id', type: 'integer', nullable: true, description: 'New subcategory id (details update).'),
|
||||
new OA\Property('custom_values', type: 'object', description: 'Custom field values (details update).'),
|
||||
],
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'OK',
|
||||
content: new OA\JsonContent(properties: [new OA\Property('data', ref: '#/components/schemas/Ticket')]),
|
||||
),
|
||||
new OA\Response(response: 403, description: 'Missing the tickets:write ability'),
|
||||
new OA\Response(response: 404, description: 'Ticket not found'),
|
||||
new OA\Response(response: 422, description: 'Validation error'),
|
||||
],
|
||||
)]
|
||||
public function update(UpdateTicketRequest $request, Ticket $ticket)
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
if (array_key_exists('status_key', $data)) {
|
||||
$this->tickets->setStatus($ticket, $data['status_key']);
|
||||
}
|
||||
|
||||
if (array_key_exists('priority_key', $data)) {
|
||||
$this->tickets->setPriority($ticket, $data['priority_key']);
|
||||
}
|
||||
|
||||
if (array_key_exists('assignee_id', $data)) {
|
||||
$this->tickets->setAssignee($ticket, $data['assignee_id'] ? User::find($data['assignee_id']) : null);
|
||||
}
|
||||
|
||||
if (array_key_exists('team_id', $data)) {
|
||||
$this->tickets->setTeam($ticket, $data['team_id'] ? Team::find($data['team_id']) : null);
|
||||
}
|
||||
|
||||
if (array_intersect(['subject', 'body', 'subcategory_id', 'custom_values'], array_keys($data))) {
|
||||
$this->tickets->updateDetails($ticket, [
|
||||
'subject' => $data['subject'] ?? $ticket->subject,
|
||||
'body' => $data['body'] ?? $ticket->body,
|
||||
'subcategory_id' => array_key_exists('subcategory_id', $data) ? $data['subcategory_id'] : $ticket->subcategory_id,
|
||||
'custom_values' => $data['custom_values'] ?? $ticket->custom_fields,
|
||||
]);
|
||||
}
|
||||
|
||||
return new TicketResource($ticket->fresh()->load(['subcategory.category', 'subcategory.customFields']));
|
||||
}
|
||||
}
|
||||
93
src/app/Http/Controllers/Api/TicketMessageController.php
Normal file
93
src/app/Http/Controllers/Api/TicketMessageController.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\StoreTicketMessageRequest;
|
||||
use App\Http\Resources\TicketMessageResource;
|
||||
use App\Models\Ticket;
|
||||
use App\Services\TicketService;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class TicketMessageController extends Controller
|
||||
{
|
||||
public function __construct(protected TicketService $tickets) {}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/tickets/{ticket}/messages',
|
||||
summary: "List a ticket's messages",
|
||||
description: "Includes internal (operator-only) notes — an API key's tickets:read ability grants the same visibility as an operator.",
|
||||
security: [['sanctum' => []]],
|
||||
tags: ['Ticket Messages'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'ticket', description: 'Ticket id.', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'OK',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property('data', type: 'array', items: new OA\Items(ref: '#/components/schemas/TicketMessage')),
|
||||
],
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 403, description: 'Missing the tickets:read ability'),
|
||||
new OA\Response(response: 404, description: 'Ticket not found'),
|
||||
],
|
||||
)]
|
||||
public function index(Ticket $ticket)
|
||||
{
|
||||
return TicketMessageResource::collection($ticket->messages()->with('attachments')->get());
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/tickets/{ticket}/messages',
|
||||
summary: 'Add a message to a ticket',
|
||||
security: [['sanctum' => []]],
|
||||
tags: ['Ticket Messages'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'ticket', description: 'Ticket id.', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'multipart/form-data',
|
||||
schema: new OA\Schema(
|
||||
required: ['body'],
|
||||
properties: [
|
||||
new OA\Property('body', type: 'string', example: 'Problem nadal występuje.'),
|
||||
new OA\Property('internal', type: 'boolean', description: 'Whether this is an internal (operator-only) note. Defaults to false.'),
|
||||
new OA\Property('author_name', type: 'string', nullable: true, description: "Display name for the message author. Defaults to the API key's name."),
|
||||
new OA\Property('attachments', type: 'array', items: new OA\Items(type: 'string', format: 'binary'), description: 'Files to attach to the message.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'OK',
|
||||
content: new OA\JsonContent(properties: [new OA\Property('data', ref: '#/components/schemas/TicketMessage')]),
|
||||
),
|
||||
new OA\Response(response: 403, description: 'Missing the tickets:write ability'),
|
||||
new OA\Response(response: 404, description: 'Ticket not found'),
|
||||
new OA\Response(response: 422, description: 'Validation error'),
|
||||
],
|
||||
)]
|
||||
public function store(StoreTicketMessageRequest $request, Ticket $ticket)
|
||||
{
|
||||
$apiClient = $request->user();
|
||||
|
||||
$message = $this->tickets->apiMessage(
|
||||
$ticket,
|
||||
$apiClient,
|
||||
$request->validated('body'),
|
||||
(bool) $request->boolean('internal'),
|
||||
$request->validated('author_name') ?: $apiClient->name,
|
||||
$request->file('attachments', []),
|
||||
);
|
||||
|
||||
return new TicketMessageResource($message->load('attachments'));
|
||||
}
|
||||
}
|
||||
82
src/app/Http/Controllers/Api/UserController.php
Normal file
82
src/app/Http/Controllers/Api/UserController.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
#[OA\Get(
|
||||
path: '/users',
|
||||
summary: 'List users',
|
||||
security: [['sanctum' => []]],
|
||||
tags: ['Users'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'role',
|
||||
description: 'Filter by role key.',
|
||||
in: 'query',
|
||||
schema: new OA\Schema(type: 'string', enum: ['client', 'operator', 'admin']),
|
||||
example: 'operator',
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'per_page',
|
||||
description: 'Results per page (max 100).',
|
||||
in: 'query',
|
||||
schema: new OA\Schema(type: 'integer'),
|
||||
example: 25,
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'OK',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property('data', type: 'array', items: new OA\Items(ref: '#/components/schemas/User')),
|
||||
],
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 403, description: 'Missing the users:read ability'),
|
||||
],
|
||||
)]
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = User::query()->with('fieldValues');
|
||||
|
||||
if ($request->filled('role')) {
|
||||
$query->withRole($request->input('role'));
|
||||
}
|
||||
|
||||
$perPage = min((int) $request->input('per_page', 25) ?: 25, 100);
|
||||
|
||||
return UserResource::collection($query->orderBy('name')->paginate($perPage));
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/users/{user}',
|
||||
summary: 'Get a user',
|
||||
security: [['sanctum' => []]],
|
||||
tags: ['Users'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'user', description: 'User id.', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'OK',
|
||||
content: new OA\JsonContent(properties: [new OA\Property('data', ref: '#/components/schemas/User')]),
|
||||
),
|
||||
new OA\Response(response: 403, description: 'Missing the users:read ability'),
|
||||
new OA\Response(response: 404, description: 'User not found'),
|
||||
],
|
||||
)]
|
||||
public function show(User $user)
|
||||
{
|
||||
return new UserResource($user->load('fieldValues'));
|
||||
}
|
||||
}
|
||||
23
src/app/Http/Controllers/Controller.php
Normal file
23
src/app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Info(
|
||||
title: 'Servicedesk API',
|
||||
version: '1.0.0',
|
||||
description: <<<'EOT'
|
||||
Dokumentacja REST API systemu Servicedesk.
|
||||
|
||||
Wszystkie endpointy wymagają nagłówka `Authorization: Bearer {klucz_api}`.
|
||||
Klucz API wygenerujesz w panelu administratora, w zakładce **Ustawienia → Klucze API**.
|
||||
Każdy klucz ma przypisany zestaw uprawnień (`abilities`) ograniczający dostęp do
|
||||
poszczególnych grup endpointów (np. `tickets:read`, `tickets:write`,
|
||||
`dictionaries:read`, `users:read`).
|
||||
EOT,
|
||||
)]
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
21
src/app/Http/Middleware/EnsureRole.php
Normal file
21
src/app/Http/Middleware/EnsureRole.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureRole
|
||||
{
|
||||
public function handle(Request $request, Closure $next, string ...$roles): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user || ! array_intersect($roles, $user->roles ?? [])) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
35
src/app/Http/Requests/Api/StoreTicketMessageRequest.php
Normal file
35
src/app/Http/Requests/Api/StoreTicketMessageRequest.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class StoreTicketMessageRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'body' => ['required', 'string'],
|
||||
'internal' => ['sometimes', 'boolean'],
|
||||
'author_name' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'attachments' => ['sometimes', 'array'],
|
||||
'attachments.*' => ['file'],
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator) {
|
||||
if ($error = Settings::validateAttachments($this->file('attachments', []))) {
|
||||
$validator->errors()->add('attachments', $error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
38
src/app/Http/Requests/Api/StoreTicketRequest.php
Normal file
38
src/app/Http/Requests/Api/StoreTicketRequest.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class StoreTicketRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'email'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'subcategory_id' => ['nullable', 'integer', 'exists:subcategories,id'],
|
||||
'subject' => ['required', 'string', 'max:255'],
|
||||
'body' => ['required', 'string'],
|
||||
'custom_values' => ['sometimes', 'array'],
|
||||
'attachments' => ['sometimes', 'array'],
|
||||
'attachments.*' => ['file'],
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator) {
|
||||
if ($error = Settings::validateAttachments($this->file('attachments', []))) {
|
||||
$validator->errors()->add('attachments', $error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
27
src/app/Http/Requests/Api/UpdateTicketRequest.php
Normal file
27
src/app/Http/Requests/Api/UpdateTicketRequest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateTicketRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status_key' => ['sometimes', 'string', 'exists:statuses,key'],
|
||||
'priority_key' => ['sometimes', 'string', 'exists:priorities,key'],
|
||||
'assignee_id' => ['sometimes', 'nullable', 'integer', 'exists:users,id'],
|
||||
'team_id' => ['sometimes', 'nullable', 'integer', 'exists:teams,id'],
|
||||
'subject' => ['sometimes', 'string', 'max:255'],
|
||||
'body' => ['sometimes', 'string'],
|
||||
'subcategory_id' => ['sometimes', 'nullable', 'integer', 'exists:subcategories,id'],
|
||||
'custom_values' => ['sometimes', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
29
src/app/Http/Resources/CategoryResource.php
Normal file
29
src/app/Http/Resources/CategoryResource.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema(
|
||||
schema: 'Category',
|
||||
properties: [
|
||||
new OA\Property('id', type: 'integer', example: 1),
|
||||
new OA\Property('name', type: 'string', example: 'Sieć'),
|
||||
new OA\Property('description', type: 'string', nullable: true, example: 'Zgłoszenia dotyczące sieci i VPN'),
|
||||
new OA\Property('subcategories', type: 'array', items: new OA\Items(ref: '#/components/schemas/Subcategory')),
|
||||
],
|
||||
)]
|
||||
class CategoryResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'subcategories' => SubcategoryResource::collection($this->whenLoaded('subcategories')),
|
||||
];
|
||||
}
|
||||
}
|
||||
33
src/app/Http/Resources/CustomFieldResource.php
Normal file
33
src/app/Http/Resources/CustomFieldResource.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema(
|
||||
schema: 'CustomField',
|
||||
properties: [
|
||||
new OA\Property('id', type: 'integer', example: 5),
|
||||
new OA\Property('label', type: 'string', example: 'Numer inwentarzowy'),
|
||||
new OA\Property('type', type: 'string', enum: ['text', 'textarea', 'select', 'checkbox', 'date', 'number'], example: 'text'),
|
||||
new OA\Property('required', type: 'boolean', example: false),
|
||||
new OA\Property('options', type: 'array', nullable: true, items: new OA\Items(type: 'string')),
|
||||
new OA\Property('sort_order', type: 'integer', example: 0),
|
||||
],
|
||||
)]
|
||||
class CustomFieldResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'label' => $this->label,
|
||||
'type' => $this->type,
|
||||
'required' => $this->required,
|
||||
'options' => $this->options,
|
||||
'sort_order' => $this->sort_order,
|
||||
];
|
||||
}
|
||||
}
|
||||
29
src/app/Http/Resources/PriorityResource.php
Normal file
29
src/app/Http/Resources/PriorityResource.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema(
|
||||
schema: 'Priority',
|
||||
properties: [
|
||||
new OA\Property('key', type: 'string', example: 'high'),
|
||||
new OA\Property('label', type: 'string', example: 'Wysoki'),
|
||||
new OA\Property('color', type: 'string', example: '#e02424'),
|
||||
new OA\Property('sort_order', type: 'integer', example: 2),
|
||||
],
|
||||
)]
|
||||
class PriorityResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'key' => $this->key,
|
||||
'label' => $this->label,
|
||||
'color' => $this->color,
|
||||
'sort_order' => $this->sort_order,
|
||||
];
|
||||
}
|
||||
}
|
||||
31
src/app/Http/Resources/StatusResource.php
Normal file
31
src/app/Http/Resources/StatusResource.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema(
|
||||
schema: 'Status',
|
||||
properties: [
|
||||
new OA\Property('key', type: 'string', example: 'in_progress'),
|
||||
new OA\Property('label', type: 'string', example: 'W trakcie'),
|
||||
new OA\Property('color', type: 'string', example: '#f59e0b'),
|
||||
new OA\Property('stage', type: 'string', example: 'open'),
|
||||
new OA\Property('sort_order', type: 'integer', example: 1),
|
||||
],
|
||||
)]
|
||||
class StatusResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'key' => $this->key,
|
||||
'label' => $this->label,
|
||||
'color' => $this->color,
|
||||
'stage' => $this->stage,
|
||||
'sort_order' => $this->sort_order,
|
||||
];
|
||||
}
|
||||
}
|
||||
31
src/app/Http/Resources/SubcategoryResource.php
Normal file
31
src/app/Http/Resources/SubcategoryResource.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema(
|
||||
schema: 'Subcategory',
|
||||
properties: [
|
||||
new OA\Property('id', type: 'integer', example: 2),
|
||||
new OA\Property('name', type: 'string', example: 'VPN'),
|
||||
new OA\Property('description', type: 'string', nullable: true, example: 'Problemy z połączeniem VPN'),
|
||||
new OA\Property('default_priority_key', type: 'string', nullable: true, example: 'medium'),
|
||||
new OA\Property('custom_fields', type: 'array', items: new OA\Items(ref: '#/components/schemas/CustomField')),
|
||||
],
|
||||
)]
|
||||
class SubcategoryResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'default_priority_key' => $this->default_priority_key,
|
||||
'custom_fields' => CustomFieldResource::collection($this->whenLoaded('customFields')),
|
||||
];
|
||||
}
|
||||
}
|
||||
25
src/app/Http/Resources/TeamResource.php
Normal file
25
src/app/Http/Resources/TeamResource.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema(
|
||||
schema: 'Team',
|
||||
properties: [
|
||||
new OA\Property('id', type: 'integer', example: 1),
|
||||
new OA\Property('name', type: 'string', example: 'Wsparcie IT'),
|
||||
],
|
||||
)]
|
||||
class TeamResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
32
src/app/Http/Resources/TicketAttachmentResource.php
Normal file
32
src/app/Http/Resources/TicketAttachmentResource.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema(
|
||||
schema: 'TicketAttachment',
|
||||
properties: [
|
||||
new OA\Property('id', type: 'integer', example: 7),
|
||||
new OA\Property('original_name', type: 'string', example: 'zrzut-ekranu.png'),
|
||||
new OA\Property('size', type: 'integer', example: 204800),
|
||||
new OA\Property('mime', type: 'string', example: 'image/png'),
|
||||
new OA\Property('url', type: 'string', format: 'uri', example: 'https://servicedesk.example.pl/storage/attachments/zrzut-ekranu.png'),
|
||||
],
|
||||
)]
|
||||
class TicketAttachmentResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'original_name' => $this->original_name,
|
||||
'size' => $this->size,
|
||||
'mime' => $this->mime,
|
||||
'url' => Storage::disk('public')->url($this->path),
|
||||
];
|
||||
}
|
||||
}
|
||||
41
src/app/Http/Resources/TicketMessageResource.php
Normal file
41
src/app/Http/Resources/TicketMessageResource.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema(
|
||||
schema: 'TicketMessage',
|
||||
properties: [
|
||||
new OA\Property('id', type: 'integer', example: 10),
|
||||
new OA\Property('ticket_id', type: 'integer', example: 42),
|
||||
new OA\Property('author_name', type: 'string', example: 'Jan Kowalski'),
|
||||
new OA\Property('role', type: 'string', example: 'customer'),
|
||||
new OA\Property('internal', type: 'boolean', example: false),
|
||||
new OA\Property('body', type: 'string', example: 'Problem nadal występuje.'),
|
||||
new OA\Property('edited', type: 'boolean', example: false),
|
||||
new OA\Property('attachments', type: 'array', items: new OA\Items(ref: '#/components/schemas/TicketAttachment')),
|
||||
new OA\Property('created_at', type: 'string', format: 'date-time'),
|
||||
new OA\Property('updated_at', type: 'string', format: 'date-time'),
|
||||
],
|
||||
)]
|
||||
class TicketMessageResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'ticket_id' => $this->ticket_id,
|
||||
'author_name' => $this->author_name,
|
||||
'role' => $this->role,
|
||||
'internal' => $this->internal,
|
||||
'body' => $this->body,
|
||||
'edited' => $this->edited,
|
||||
'attachments' => TicketAttachmentResource::collection($this->whenLoaded('attachments')),
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
86
src/app/Http/Resources/TicketResource.php
Normal file
86
src/app/Http/Resources/TicketResource.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema(
|
||||
schema: 'Ticket',
|
||||
properties: [
|
||||
new OA\Property('id', type: 'integer', example: 42),
|
||||
new OA\Property('number', type: 'string', example: '1042'),
|
||||
new OA\Property('subject', type: 'string', example: 'VPN nie działa'),
|
||||
new OA\Property('body', type: 'string', example: 'Nie mogę się połączyć z VPN od rana.'),
|
||||
new OA\Property('email', type: 'string', format: 'email', example: 'jan.kowalski@firma.pl'),
|
||||
new OA\Property('name', type: 'string', example: 'Jan Kowalski'),
|
||||
new OA\Property('customer_id', type: 'integer', nullable: true, example: 5),
|
||||
new OA\Property('assignee_id', type: 'integer', nullable: true, example: 3),
|
||||
new OA\Property('team_id', type: 'integer', nullable: true, example: 1),
|
||||
new OA\Property(
|
||||
'status',
|
||||
properties: [
|
||||
new OA\Property('key', type: 'string', example: 'in_progress'),
|
||||
new OA\Property('label', type: 'string', example: 'W trakcie'),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
new OA\Property(
|
||||
'priority',
|
||||
properties: [
|
||||
new OA\Property('key', type: 'string', example: 'high'),
|
||||
new OA\Property('label', type: 'string', example: 'Wysoki'),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
new OA\Property(
|
||||
'category',
|
||||
properties: [
|
||||
new OA\Property('id', type: 'integer', example: 1),
|
||||
new OA\Property('name', type: 'string', example: 'Sieć'),
|
||||
new OA\Property('description', type: 'string', nullable: true),
|
||||
new OA\Property('subcategory', ref: '#/components/schemas/Subcategory'),
|
||||
],
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
),
|
||||
new OA\Property('custom_fields', type: 'object', nullable: true, example: ['5' => 'INV-2024-001']),
|
||||
new OA\Property('created_at', type: 'string', format: 'date-time'),
|
||||
new OA\Property('updated_at', type: 'string', format: 'date-time'),
|
||||
],
|
||||
)]
|
||||
class TicketResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'number' => $this->number,
|
||||
'subject' => $this->subject,
|
||||
'body' => $this->body,
|
||||
'email' => $this->email,
|
||||
'name' => $this->name,
|
||||
'customer_id' => $this->customer_id,
|
||||
'assignee_id' => $this->assignee_id,
|
||||
'team_id' => $this->team_id,
|
||||
'status' => [
|
||||
'key' => $this->status_key,
|
||||
'label' => $this->statusLabel(),
|
||||
],
|
||||
'priority' => [
|
||||
'key' => $this->priority_key,
|
||||
'label' => $this->priorityLabel(),
|
||||
],
|
||||
'category' => $this->subcategory ? [
|
||||
'id' => $this->subcategory->category->id,
|
||||
'name' => $this->subcategory->category->name,
|
||||
'description' => $this->subcategory->category->description,
|
||||
'subcategory' => new SubcategoryResource($this->subcategory),
|
||||
] : null,
|
||||
'custom_fields' => $this->customFieldEntries(),
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
37
src/app/Http/Resources/UserResource.php
Normal file
37
src/app/Http/Resources/UserResource.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema(
|
||||
schema: 'User',
|
||||
properties: [
|
||||
new OA\Property('id', type: 'integer', example: 3),
|
||||
new OA\Property('name', type: 'string', example: 'Jan Kowalski'),
|
||||
new OA\Property('email', type: 'string', format: 'email', example: 'jan.kowalski@firma.pl'),
|
||||
new OA\Property('roles', type: 'array', items: new OA\Items(type: 'string', enum: ['client', 'operator', 'admin'])),
|
||||
new OA\Property('custom_fields', type: 'array', items: new OA\Items(
|
||||
properties: [
|
||||
new OA\Property('label', type: 'string', example: 'Dział'),
|
||||
new OA\Property('value', type: 'string', example: 'Księgowość'),
|
||||
],
|
||||
type: 'object',
|
||||
)),
|
||||
],
|
||||
)]
|
||||
class UserResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'roles' => $this->roles,
|
||||
'custom_fields' => $this->customFieldEntries(),
|
||||
];
|
||||
}
|
||||
}
|
||||
21
src/app/Ldap/Handlers/AssignDefaultRole.php
Normal file
21
src/app/Ldap/Handlers/AssignDefaultRole.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ldap\Handlers;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use LdapRecord\Models\Model as LdapModel;
|
||||
|
||||
/**
|
||||
* Runs during LDAP -> database sync (config/auth.php: sync_attributes).
|
||||
* Brand new local accounts default to the "client" role; existing accounts
|
||||
* (including ones an admin already granted extra roles to) are left alone.
|
||||
*/
|
||||
class AssignDefaultRole
|
||||
{
|
||||
public function __invoke(LdapModel $ldap, Model $eloquent): void
|
||||
{
|
||||
if (empty($eloquent->roles)) {
|
||||
$eloquent->roles = ['client'];
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/app/Ldap/Handlers/SyncUserFieldsFromLdap.php
Normal file
31
src/app/Ldap/Handlers/SyncUserFieldsFromLdap.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ldap\Handlers;
|
||||
|
||||
use App\Models\UserField;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use LdapRecord\Models\Model as LdapModel;
|
||||
|
||||
/**
|
||||
* Runs during LDAP -> database sync (config/auth.php: sync_attributes).
|
||||
* Fills the admin-defined "user field" values (Panel > Zespół > Pola
|
||||
* dodatkowe) from whichever LDAP attribute each field is mapped to, so they
|
||||
* stay current on every login without a manual sync.
|
||||
*/
|
||||
class SyncUserFieldsFromLdap
|
||||
{
|
||||
public function __invoke(LdapModel $ldap, Model $eloquent): void
|
||||
{
|
||||
$values = $eloquent->custom_field_values ?? [];
|
||||
|
||||
foreach (UserField::query()->whereNotNull('ldap_attribute')->where('ldap_attribute', '!=', '')->get() as $field) {
|
||||
$value = $ldap->getFirstAttribute($field->ldap_attribute);
|
||||
|
||||
if ($value !== null) {
|
||||
$values[$field->id] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$eloquent->custom_field_values = $values;
|
||||
}
|
||||
}
|
||||
33
src/app/Ldap/LldapUser.php
Normal file
33
src/app/Ldap/LldapUser.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ldap;
|
||||
|
||||
use LdapRecord\Models\OpenLDAP\User as OpenLdapUser;
|
||||
|
||||
/**
|
||||
* LLDAP (https://github.com/lldap/lldap) speaks an OpenLDAP-compatible schema
|
||||
* (inetOrgPerson under ou=people), so the stock OpenLDAP user model applies as-is.
|
||||
*/
|
||||
class LldapUser extends OpenLdapUser
|
||||
{
|
||||
/**
|
||||
* LLDAP (and OpenLDAP generally) exposes entryUUID as the stable per-entry
|
||||
* identifier — unlike Active Directory's binary objectGUID, which this
|
||||
* base model defaults to and which LLDAP does not provide.
|
||||
*/
|
||||
protected string $guidKey = 'entryuuid';
|
||||
|
||||
/**
|
||||
* LLDAP's real user entries (verified against the live directory) carry
|
||||
* inetOrgPerson/posixAccount/mailAccount/person — no organizationalPerson
|
||||
* and no explicit "top" — unlike the generic OpenLDAP\User default. Getting
|
||||
* this wrong means the object-class query scope matches zero entries and
|
||||
* every login silently fails.
|
||||
*/
|
||||
public static array $objectClasses = [
|
||||
'inetOrgPerson',
|
||||
'posixAccount',
|
||||
'mailAccount',
|
||||
'person',
|
||||
];
|
||||
}
|
||||
120
src/app/Livewire/Admin/ApiKeys.php
Normal file
120
src/app/Livewire/Admin/ApiKeys.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\ApiClient;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class ApiKeys extends Component
|
||||
{
|
||||
public bool $formOpen = false;
|
||||
|
||||
public array $form = ['name' => '', 'description' => '', 'abilities' => []];
|
||||
|
||||
/**
|
||||
* Only ever populated right after createToken() — the plaintext secret
|
||||
* is never stored anywhere (Sanctum only keeps the hash), so this is the
|
||||
* one and only chance to show it. Cleared on every other action so a
|
||||
* page refresh or subsequent click never brings it back.
|
||||
*/
|
||||
public ?string $newTokenPlaintext = null;
|
||||
|
||||
public ?string $newTokenClientName = null;
|
||||
|
||||
public static function availableAbilities(): array
|
||||
{
|
||||
return [
|
||||
'tickets:read' => 'Odczyt zgłoszeń',
|
||||
'tickets:write' => 'Zapis zgłoszeń (tworzenie, aktualizacja, wiadomości)',
|
||||
'dictionaries:read' => 'Odczyt słowników (kategorie, statusy, priorytety, zespoły)',
|
||||
'users:read' => 'Odczyt użytkowników',
|
||||
];
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function apiClients(): Collection
|
||||
{
|
||||
return ApiClient::query()
|
||||
->with(['tokens' => fn ($q) => $q->latest()])
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function openForm(): void
|
||||
{
|
||||
$this->reset('form');
|
||||
$this->newTokenPlaintext = null;
|
||||
$this->newTokenClientName = null;
|
||||
$this->resetErrorBag();
|
||||
$this->formOpen = true;
|
||||
}
|
||||
|
||||
public function closeForm(): void
|
||||
{
|
||||
$this->formOpen = false;
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$this->validate([
|
||||
'form.name' => ['required', 'string', 'max:255'],
|
||||
'form.description' => ['nullable', 'string', 'max:1000'],
|
||||
'form.abilities' => ['required', 'array', 'min:1'],
|
||||
'form.abilities.*' => ['string', 'in:'.implode(',', array_keys(static::availableAbilities()))],
|
||||
]);
|
||||
|
||||
$client = ApiClient::query()->create([
|
||||
'name' => $this->form['name'],
|
||||
'description' => $this->form['description'] ?: null,
|
||||
'created_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
$token = $client->createToken($this->form['name'], $this->form['abilities']);
|
||||
|
||||
$this->newTokenPlaintext = $token->plainTextToken;
|
||||
$this->newTokenClientName = $client->name;
|
||||
$this->formOpen = false;
|
||||
unset($this->apiClients);
|
||||
}
|
||||
|
||||
public function revoke(int $apiClientId): void
|
||||
{
|
||||
$this->newTokenPlaintext = null;
|
||||
$this->newTokenClientName = null;
|
||||
|
||||
$client = ApiClient::query()->findOrFail($apiClientId);
|
||||
$client->tokens()->delete();
|
||||
$client->update(['revoked_at' => now()]);
|
||||
|
||||
unset($this->apiClients);
|
||||
}
|
||||
|
||||
public function regenerate(int $apiClientId): void
|
||||
{
|
||||
$client = ApiClient::query()->findOrFail($apiClientId);
|
||||
$abilities = $client->tokens()->latest()->first()?->abilities ?? [];
|
||||
|
||||
$client->tokens()->delete();
|
||||
$token = $client->createToken($client->name, $abilities);
|
||||
$client->update(['revoked_at' => null]);
|
||||
|
||||
$this->newTokenPlaintext = $token->plainTextToken;
|
||||
$this->newTokenClientName = $client->name;
|
||||
|
||||
unset($this->apiClients);
|
||||
}
|
||||
|
||||
public function dismissNewToken(): void
|
||||
{
|
||||
$this->newTokenPlaintext = null;
|
||||
$this->newTokenClientName = null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.api-keys');
|
||||
}
|
||||
}
|
||||
1402
src/app/Livewire/Admin/Panel.php
Normal file
1402
src/app/Livewire/Admin/Panel.php
Normal file
File diff suppressed because it is too large
Load Diff
70
src/app/Livewire/Auth/Login.php
Normal file
70
src/app/Livewire/Auth/Login.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
class Login extends Component
|
||||
{
|
||||
public string $username = '';
|
||||
|
||||
public string $password = '';
|
||||
|
||||
public ?string $error = null;
|
||||
|
||||
/**
|
||||
* Where to land after a successful login — e.g. set by the guest ticket
|
||||
* confirmation screen's "Zaloguj się" button so it can jump straight to
|
||||
* the just-created ticket instead of the user's default area.
|
||||
*/
|
||||
#[Url]
|
||||
public ?string $redirect = null;
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$this->error = null;
|
||||
|
||||
$attribute = Settings::ldapUsernameAttribute();
|
||||
|
||||
// Local accounts (created with a password from the admin panel) don't
|
||||
// necessarily exist in LDAP under this attribute, so we also let the
|
||||
// provider fall back to matching by e-mail + local password.
|
||||
$ok = Auth::attempt([
|
||||
$attribute => $this->username,
|
||||
'password' => $this->password,
|
||||
'fallback' => ['email' => $this->username],
|
||||
]);
|
||||
|
||||
if (! $ok) {
|
||||
$this->error = 'Nieprawidłowa nazwa użytkownika lub hasło.';
|
||||
$this->password = '';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
request()->session()->regenerate();
|
||||
|
||||
$this->redirect($this->safeRedirectTarget() ?? Auth::user()->defaultArea(), navigate: false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only follow the ?redirect= target when it's a same-app relative path —
|
||||
* never an absolute/external URL, to avoid it being abused as an open redirect.
|
||||
*/
|
||||
protected function safeRedirectTarget(): ?string
|
||||
{
|
||||
if (! $this->redirect || ! str_starts_with($this->redirect, '/') || str_starts_with($this->redirect, '//')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->redirect;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.auth.login');
|
||||
}
|
||||
}
|
||||
44
src/app/Livewire/Client/Dashboard.php
Normal file
44
src/app/Livewire/Client/Dashboard.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Client;
|
||||
|
||||
use App\Models\Status;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class Dashboard extends Component
|
||||
{
|
||||
public string $tab = 'current';
|
||||
|
||||
#[Computed]
|
||||
public function tickets()
|
||||
{
|
||||
return Auth::user()->ticketsAsCustomer()
|
||||
->with('subcategory.category')
|
||||
->orderByDesc('updated_at')
|
||||
->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function currentTickets()
|
||||
{
|
||||
return $this->tickets->whereNotIn('status_key', Status::closedKeys());
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function archiveTickets()
|
||||
{
|
||||
return $this->tickets->whereIn('status_key', Status::closedKeys());
|
||||
}
|
||||
|
||||
public function setTab(string $tab): void
|
||||
{
|
||||
$this->tab = $tab;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.client.dashboard');
|
||||
}
|
||||
}
|
||||
123
src/app/Livewire/Client/NewTicket.php
Normal file
123
src/app/Livewire/Client/NewTicket.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Client;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Subcategory;
|
||||
use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class NewTicket extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public int $step = 1;
|
||||
|
||||
public ?int $categoryId = null;
|
||||
|
||||
public ?int $subcategoryId = null;
|
||||
|
||||
public string $subject = '';
|
||||
|
||||
public string $body = '';
|
||||
|
||||
public array $customValues = [];
|
||||
|
||||
public array $attachments = [];
|
||||
|
||||
#[Computed]
|
||||
public function categories()
|
||||
{
|
||||
return Category::query()->with('subcategories')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function selectedCategory(): ?Category
|
||||
{
|
||||
return $this->categoryId ? Category::query()->with('subcategories')->find($this->categoryId) : null;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function selectedSubcategory(): ?Subcategory
|
||||
{
|
||||
return $this->subcategoryId ? Subcategory::query()->with('customFields')->find($this->subcategoryId) : null;
|
||||
}
|
||||
|
||||
public function selectCategory(int $id): void
|
||||
{
|
||||
$this->categoryId = $id;
|
||||
$this->subcategoryId = null;
|
||||
$this->step = 2;
|
||||
}
|
||||
|
||||
public function selectSubcategory(int $id): void
|
||||
{
|
||||
$this->subcategoryId = $id;
|
||||
$this->step = 3;
|
||||
}
|
||||
|
||||
public function backToCategory(): void
|
||||
{
|
||||
$this->step = 1;
|
||||
}
|
||||
|
||||
public function backToSubcategory(): void
|
||||
{
|
||||
$this->step = 2;
|
||||
}
|
||||
|
||||
public function updatedAttachments(): void
|
||||
{
|
||||
if (! $this->attachments) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($error = Settings::validateAttachments($this->attachments)) {
|
||||
$this->attachments = [];
|
||||
$this->addError('attachments', $error);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeAttachment(int $index): void
|
||||
{
|
||||
unset($this->attachments[$index]);
|
||||
$this->attachments = array_values($this->attachments);
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$rules = [
|
||||
'subject' => 'required|string|max:255',
|
||||
'body' => 'required|string',
|
||||
];
|
||||
|
||||
foreach ($this->selectedSubcategory?->customFields ?? [] as $field) {
|
||||
$rules["customValues.{$field->id}"] = $field->required ? 'required' : 'nullable';
|
||||
}
|
||||
|
||||
$this->validate($rules);
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => $user->email,
|
||||
'subcategory_id' => $this->subcategoryId,
|
||||
'subject' => $this->subject,
|
||||
'body' => $this->body,
|
||||
'custom_values' => $this->customValues,
|
||||
], $user);
|
||||
|
||||
app(TicketService::class)->attachFiles($ticket, $ticket->messages()->first(), $this->attachments);
|
||||
|
||||
$this->redirect(route('client.ticket', $ticket), navigate: true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.client.new-ticket');
|
||||
}
|
||||
}
|
||||
138
src/app/Livewire/Client/TicketShow.php
Normal file
138
src/app/Livewire/Client/TicketShow.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Client;
|
||||
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketMessage;
|
||||
use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class TicketShow extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public Ticket $ticket;
|
||||
|
||||
public string $reply = '';
|
||||
|
||||
public array $attachments = [];
|
||||
|
||||
public ?int $editingMessageId = null;
|
||||
|
||||
public string $editingDraft = '';
|
||||
|
||||
public ?int $pendingDeleteMessageId = null;
|
||||
|
||||
public function mount(Ticket $ticket): void
|
||||
{
|
||||
abort_unless($ticket->customer_id === Auth::id(), 403);
|
||||
|
||||
$this->ticket = $ticket;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function ticketMessages()
|
||||
{
|
||||
return $this->ticket->publicMessages()->with(['author', 'authorLink.role', 'attachments'])->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function otherTickets()
|
||||
{
|
||||
return Auth::user()->ticketsAsCustomer()->where('id', '!=', $this->ticket->id)->get();
|
||||
}
|
||||
|
||||
public function updatedAttachments(): void
|
||||
{
|
||||
if (! $this->attachments) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($error = Settings::validateAttachments($this->attachments)) {
|
||||
$this->attachments = [];
|
||||
$this->addError('attachments', $error);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeAttachment(int $index): void
|
||||
{
|
||||
unset($this->attachments[$index]);
|
||||
$this->attachments = array_values($this->attachments);
|
||||
}
|
||||
|
||||
public function sendReply(): void
|
||||
{
|
||||
$this->validate(['reply' => 'required|string']);
|
||||
|
||||
app(TicketService::class)->clientReply($this->ticket, Auth::user(), $this->reply, $this->attachments);
|
||||
$this->reset(['reply', 'attachments']);
|
||||
unset($this->ticketMessages);
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
app(TicketService::class)->setStatus($this->ticket, 'closed');
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
public function reopen(): void
|
||||
{
|
||||
app(TicketService::class)->setStatus($this->ticket, 'open');
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
public function startEdit(int $messageId, string $body): void
|
||||
{
|
||||
$message = TicketMessage::query()->findOrFail($messageId);
|
||||
abort_unless($message->ticket_id === $this->ticket->id && $message->author_id === Auth::id() && $message->role === 'client', 403);
|
||||
|
||||
$this->editingMessageId = $messageId;
|
||||
$this->editingDraft = $body;
|
||||
}
|
||||
|
||||
public function cancelEdit(): void
|
||||
{
|
||||
$this->editingMessageId = null;
|
||||
$this->editingDraft = '';
|
||||
}
|
||||
|
||||
public function saveEdit(): void
|
||||
{
|
||||
$message = TicketMessage::query()->findOrFail($this->editingMessageId);
|
||||
abort_unless($message->ticket_id === $this->ticket->id && $message->author_id === Auth::id() && $message->role === 'client', 403);
|
||||
|
||||
$message->update(['body' => $this->editingDraft, 'edited' => true]);
|
||||
$this->cancelEdit();
|
||||
unset($this->ticketMessages);
|
||||
}
|
||||
|
||||
public function requestDelete(int $messageId): void
|
||||
{
|
||||
$this->pendingDeleteMessageId = $messageId;
|
||||
}
|
||||
|
||||
public function cancelDelete(): void
|
||||
{
|
||||
$this->pendingDeleteMessageId = null;
|
||||
}
|
||||
|
||||
public function confirmDelete(): void
|
||||
{
|
||||
$message = TicketMessage::query()->findOrFail($this->pendingDeleteMessageId);
|
||||
abort_unless($message->ticket_id === $this->ticket->id && $message->author_id === Auth::id() && $message->role === 'client', 403);
|
||||
|
||||
$message->delete();
|
||||
$this->pendingDeleteMessageId = null;
|
||||
unset($this->ticketMessages);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.client.ticket-show');
|
||||
}
|
||||
}
|
||||
159
src/app/Livewire/Landing.php
Normal file
159
src/app/Livewire/Landing.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Subcategory;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Services\LdapUserProvisioner;
|
||||
use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class Landing extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public int $step = 1;
|
||||
|
||||
public ?int $categoryId = null;
|
||||
|
||||
public ?int $subcategoryId = null;
|
||||
|
||||
public string $email = '';
|
||||
|
||||
public string $subject = '';
|
||||
|
||||
public string $body = '';
|
||||
|
||||
public array $customValues = [];
|
||||
|
||||
public array $attachments = [];
|
||||
|
||||
public ?int $submittedTicketId = null;
|
||||
|
||||
#[Computed]
|
||||
public function categories()
|
||||
{
|
||||
return Category::query()->with('subcategories')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function selectedCategory(): ?Category
|
||||
{
|
||||
return $this->categoryId ? Category::query()->with('subcategories')->find($this->categoryId) : null;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function selectedSubcategory(): ?Subcategory
|
||||
{
|
||||
return $this->subcategoryId ? Subcategory::query()->with('customFields')->find($this->subcategoryId) : null;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function submittedTicket(): ?Ticket
|
||||
{
|
||||
return $this->submittedTicketId ? Ticket::query()->with('subcategory.category')->find($this->submittedTicketId) : null;
|
||||
}
|
||||
|
||||
public function selectCategory(int $id): void
|
||||
{
|
||||
$this->categoryId = $id;
|
||||
$this->subcategoryId = null;
|
||||
$this->step = 2;
|
||||
}
|
||||
|
||||
public function selectSubcategory(int $id): void
|
||||
{
|
||||
$this->subcategoryId = $id;
|
||||
$this->step = 3;
|
||||
}
|
||||
|
||||
public function backToCategory(): void
|
||||
{
|
||||
$this->step = 1;
|
||||
}
|
||||
|
||||
public function backToSubcategory(): void
|
||||
{
|
||||
$this->step = 2;
|
||||
}
|
||||
|
||||
public function updatedAttachments(): void
|
||||
{
|
||||
if (! $this->attachments) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($error = Settings::validateAttachments($this->attachments)) {
|
||||
$this->attachments = [];
|
||||
$this->addError('attachments', $error);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeAttachment(int $index): void
|
||||
{
|
||||
unset($this->attachments[$index]);
|
||||
$this->attachments = array_values($this->attachments);
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$rules = [
|
||||
'email' => 'required|email',
|
||||
'subject' => 'required|string|max:255',
|
||||
'body' => 'required|string',
|
||||
];
|
||||
|
||||
foreach ($this->selectedSubcategory?->customFields ?? [] as $field) {
|
||||
$key = "customValues.{$field->id}";
|
||||
$rules[$key] = $field->required ? 'required' : 'nullable';
|
||||
}
|
||||
|
||||
$this->validate($rules);
|
||||
|
||||
if (Settings::bool('restrict_tickets_to_ldap') && ! $this->emailIsKnown($this->email)) {
|
||||
$this->addError('email', 'Nie znaleziono użytkownika o podanym adresie e-mail.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => $this->email,
|
||||
'subcategory_id' => $this->subcategoryId,
|
||||
'subject' => $this->subject,
|
||||
'body' => $this->body,
|
||||
'custom_values' => $this->customValues,
|
||||
], null);
|
||||
|
||||
app(TicketService::class)->attachFiles($ticket, $ticket->messages()->first(), $this->attachments);
|
||||
|
||||
$this->submittedTicketId = $ticket->id;
|
||||
$this->reset(['step', 'categoryId', 'subcategoryId', 'email', 'subject', 'body', 'customValues', 'attachments']);
|
||||
$this->step = 1;
|
||||
}
|
||||
|
||||
public function resetForm(): void
|
||||
{
|
||||
$this->submittedTicketId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate for the "restrict guest ticket creation to LDAP" setting — allows
|
||||
* an already-provisioned local account too, so someone removed from LDAP
|
||||
* after their account was created doesn't lose the ability to write in.
|
||||
*/
|
||||
protected function emailIsKnown(string $email): bool
|
||||
{
|
||||
return User::query()->where('email', $email)->exists()
|
||||
|| app(LdapUserProvisioner::class)->existsInLdap($email);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.landing');
|
||||
}
|
||||
}
|
||||
138
src/app/Livewire/Operator/NewTicket.php
Normal file
138
src/app/Livewire/Operator/NewTicket.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Operator;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Subcategory;
|
||||
use App\Models\User;
|
||||
use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class NewTicket extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public int $step = 1;
|
||||
|
||||
public ?int $customerId = null;
|
||||
|
||||
public ?int $categoryId = null;
|
||||
|
||||
public ?int $subcategoryId = null;
|
||||
|
||||
public string $subject = '';
|
||||
|
||||
public string $body = '';
|
||||
|
||||
public array $customValues = [];
|
||||
|
||||
public array $attachments = [];
|
||||
|
||||
#[Computed]
|
||||
public function clients()
|
||||
{
|
||||
return User::query()->withRole('client')->orderBy('name')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function categories()
|
||||
{
|
||||
return Category::query()->with('subcategories')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function selectedCategory(): ?Category
|
||||
{
|
||||
return $this->categoryId ? Category::query()->with('subcategories')->find($this->categoryId) : null;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function selectedSubcategory(): ?Subcategory
|
||||
{
|
||||
return $this->subcategoryId ? Subcategory::query()->with('customFields')->find($this->subcategoryId) : null;
|
||||
}
|
||||
|
||||
public function selectCategory(int $id): void
|
||||
{
|
||||
$this->categoryId = $id;
|
||||
$this->subcategoryId = null;
|
||||
$this->step = 2;
|
||||
}
|
||||
|
||||
public function selectSubcategory(int $id): void
|
||||
{
|
||||
$this->subcategoryId = $id;
|
||||
$this->step = 3;
|
||||
}
|
||||
|
||||
public function backToCategory(): void
|
||||
{
|
||||
$this->step = 1;
|
||||
}
|
||||
|
||||
public function backToSubcategory(): void
|
||||
{
|
||||
$this->step = 2;
|
||||
}
|
||||
|
||||
public function updatedAttachments(): void
|
||||
{
|
||||
if (! $this->attachments) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($error = Settings::validateAttachments($this->attachments)) {
|
||||
$this->attachments = [];
|
||||
$this->addError('attachments', $error);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeAttachment(int $index): void
|
||||
{
|
||||
unset($this->attachments[$index]);
|
||||
$this->attachments = array_values($this->attachments);
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$rules = [
|
||||
'customerId' => 'required|exists:users,id',
|
||||
'subject' => 'required|string|max:255',
|
||||
'body' => 'required|string',
|
||||
];
|
||||
|
||||
foreach ($this->selectedSubcategory?->customFields ?? [] as $field) {
|
||||
$rules["customValues.{$field->id}"] = $field->required ? 'required' : 'nullable';
|
||||
}
|
||||
|
||||
$this->validate($rules);
|
||||
|
||||
$customer = User::query()->findOrFail($this->customerId);
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => $customer->email,
|
||||
'subcategory_id' => $this->subcategoryId,
|
||||
'subject' => $this->subject,
|
||||
'body' => $this->body,
|
||||
'custom_values' => $this->customValues,
|
||||
'assignee_id' => Auth::id(),
|
||||
], $customer);
|
||||
|
||||
$ticket->messages()->first()?->update([
|
||||
'body' => $ticket->body.' (zgłoszenie utworzone przez operatora w imieniu klienta)',
|
||||
]);
|
||||
|
||||
app(TicketService::class)->attachFiles($ticket, $ticket->messages()->first(), $this->attachments);
|
||||
|
||||
$this->redirect(route('operator.ticket', $ticket), navigate: true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.operator.new-ticket');
|
||||
}
|
||||
}
|
||||
320
src/app/Livewire/Operator/Queue.php
Normal file
320
src/app/Livewire/Operator/Queue.php
Normal file
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Operator;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Priority;
|
||||
use App\Models\Status;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Services\TicketService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
class Queue extends Component
|
||||
{
|
||||
public string $queue = 'all';
|
||||
|
||||
public string $filterStatus = 'all';
|
||||
|
||||
public string $filterPriority = 'all';
|
||||
|
||||
public string $filterCategory = 'all';
|
||||
|
||||
#[Url]
|
||||
public ?int $filterCustomerId = null;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public string $sortBy = 'updated_at';
|
||||
|
||||
public string $sortDir = 'desc';
|
||||
|
||||
/** @var string[] */
|
||||
public array $visibleColumns = ['number', 'subject', 'customer', 'category', 'priority', 'status', 'sla', 'assignee'];
|
||||
|
||||
/** @var int[] */
|
||||
public array $selectedIds = [];
|
||||
|
||||
public bool $pendingDeleteSelected = false;
|
||||
|
||||
#[Computed]
|
||||
public function statuses()
|
||||
{
|
||||
return Status::query()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* The status filter's option list — "Otwarte" excludes closed tickets
|
||||
* entirely (they live in their own "Zamknięte" tab), so offering it as a
|
||||
* filter there would only ever produce an empty result.
|
||||
*/
|
||||
#[Computed]
|
||||
public function filterableStatuses()
|
||||
{
|
||||
return $this->queue === 'all'
|
||||
? $this->statuses->reject(fn (Status $s) => $s->stage === 'closed')
|
||||
: $this->statuses;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function priorities()
|
||||
{
|
||||
return Priority::query()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function categories()
|
||||
{
|
||||
return Category::query()->with('subcategories')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-admin operator only ever sees the teams they're actually a
|
||||
* member of — both here (sidebar tabs) and via Ticket::visibleToOperator
|
||||
* (which scopes the ticket lists/counts to match).
|
||||
*/
|
||||
#[Computed]
|
||||
public function teams()
|
||||
{
|
||||
$query = Team::query();
|
||||
|
||||
if (! Auth::user()->isAdmin()) {
|
||||
$query->whereHas('members', fn ($q) => $q->where('users.id', Auth::id()));
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function filteredCustomer(): ?User
|
||||
{
|
||||
return $this->filterCustomerId ? User::query()->find($this->filterCustomerId) : null;
|
||||
}
|
||||
|
||||
protected function queueDefs(): array
|
||||
{
|
||||
$closedKeys = Status::closedKeys();
|
||||
|
||||
$defs = [
|
||||
'all' => ['label' => 'Otwarte', 'icon' => 'inbox', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereNotIn('status_key', $closedKeys)],
|
||||
'mine' => ['label' => 'Moje zgłoszenia', 'icon' => 'assignment_ind', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->where('assignee_id', Auth::id())],
|
||||
'unassigned' => ['label' => 'Nieprzypisane', 'icon' => 'person_off', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereNull('assignee_id')],
|
||||
'closed' => ['label' => 'Zamknięte', 'icon' => 'archive', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereIn('status_key', $closedKeys)],
|
||||
];
|
||||
|
||||
foreach ($this->teams as $team) {
|
||||
$defs['team:'.$team->id] = [
|
||||
'label' => $team->name, 'icon' => 'groups', 'group' => 'Zespoły',
|
||||
'filter' => fn ($q) => $q->where('team_id', $team->id),
|
||||
];
|
||||
}
|
||||
|
||||
return $defs;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function queueGroups(): array
|
||||
{
|
||||
$defs = $this->queueDefs();
|
||||
$groups = [];
|
||||
|
||||
foreach ($defs as $key => $def) {
|
||||
$groups[$def['group']] ??= [];
|
||||
$groups[$def['group']][] = [
|
||||
'key' => $key,
|
||||
'label' => $def['label'],
|
||||
'icon' => $def['icon'],
|
||||
'count' => ($def['filter'])(Ticket::query()->visibleToOperator(Auth::user()))->count(),
|
||||
'active' => $this->queue === $key,
|
||||
];
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function filteredTickets()
|
||||
{
|
||||
$defs = $this->queueDefs();
|
||||
$query = ($defs[$this->queue]['filter'] ?? fn ($q) => $q)(Ticket::query()->visibleToOperator(Auth::user()));
|
||||
|
||||
if ($this->filterStatus !== 'all') {
|
||||
$query->where('status_key', $this->filterStatus);
|
||||
}
|
||||
if ($this->filterPriority !== 'all') {
|
||||
$query->where('priority_key', $this->filterPriority);
|
||||
}
|
||||
if ($this->filterCategory !== 'all') {
|
||||
$query->whereHas('subcategory', fn ($q) => $q->where('category_id', $this->filterCategory));
|
||||
}
|
||||
if ($this->filterCustomerId) {
|
||||
$query->where('customer_id', $this->filterCustomerId);
|
||||
}
|
||||
if (trim($this->search) !== '') {
|
||||
$term = '%'.trim($this->search).'%';
|
||||
$query->where(fn ($q) => $q->where('number', 'like', $term)
|
||||
->orWhere('subject', 'like', $term)
|
||||
->orWhere('name', 'like', $term)
|
||||
->orWhere('email', 'like', $term));
|
||||
}
|
||||
|
||||
$tickets = $query->with(['subcategory.category', 'assignee', 'priority', 'status'])->get();
|
||||
|
||||
return $this->sortTickets($tickets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Column headers are clickable rather than relying on a SQL orderBy,
|
||||
* since two of the sortable columns (kategoria, przypisany) are derived
|
||||
* from relations/labels rather than a plain ticket column — sorting the
|
||||
* already-fetched (and typically small) collection in PHP keeps every
|
||||
* column's sort using the same display value the operator actually sees.
|
||||
*/
|
||||
protected function sortTickets($tickets)
|
||||
{
|
||||
$desc = $this->sortDir === 'desc';
|
||||
|
||||
$sorted = match ($this->sortBy) {
|
||||
'number' => $tickets->sortBy(fn (Ticket $t) => (int) $t->number, SORT_REGULAR, $desc),
|
||||
'subject' => $tickets->sortBy('subject', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
'customer' => $tickets->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
'category' => $tickets->sortBy(fn (Ticket $t) => $t->categoryLabel(), SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
'priority' => $tickets->sortBy(fn (Ticket $t) => $t->priority?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
|
||||
'status' => $tickets->sortBy(fn (Ticket $t) => $t->status?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
|
||||
'assignee' => $tickets->sortBy(fn (Ticket $t) => $t->assignee?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
default => $tickets->sortBy('updated_at', SORT_REGULAR, $desc),
|
||||
};
|
||||
|
||||
return $sorted->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function columnDefs(): array
|
||||
{
|
||||
return [
|
||||
'number' => 'Numer',
|
||||
'subject' => 'Temat',
|
||||
'customer' => 'Klient',
|
||||
'category' => 'Kategoria',
|
||||
'priority' => 'Priorytet',
|
||||
'status' => 'Status',
|
||||
'sla' => 'SLA',
|
||||
'assignee' => 'Przypisany',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* SLA isn't a stored/stable value (it's computed from "now" at render
|
||||
* time), so it's shown/hidden like any other column but excluded from
|
||||
* click-to-sort.
|
||||
*/
|
||||
public function sortableColumns(): array
|
||||
{
|
||||
return ['number', 'subject', 'customer', 'category', 'priority', 'status', 'assignee'];
|
||||
}
|
||||
|
||||
public function sortByColumn(string $column): void
|
||||
{
|
||||
if (! in_array($column, $this->sortableColumns(), true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->sortBy === $column) {
|
||||
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
$this->sortBy = $column;
|
||||
$this->sortDir = 'asc';
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleColumn(string $column): void
|
||||
{
|
||||
if (in_array($column, $this->visibleColumns, true)) {
|
||||
// Always leave at least one column visible.
|
||||
if (count($this->visibleColumns) <= 1) {
|
||||
return;
|
||||
}
|
||||
$this->visibleColumns = array_values(array_diff($this->visibleColumns, [$column]));
|
||||
} else {
|
||||
$this->visibleColumns[] = $column;
|
||||
}
|
||||
}
|
||||
|
||||
public function setQueue(string $key): void
|
||||
{
|
||||
$this->queue = $key;
|
||||
$this->selectedIds = [];
|
||||
|
||||
if ($key === 'all' && $this->filterStatus !== 'all' && Status::stageFor($this->filterStatus) === 'closed') {
|
||||
$this->filterStatus = 'all';
|
||||
}
|
||||
}
|
||||
|
||||
public function clearCustomerFilter(): void
|
||||
{
|
||||
$this->filterCustomerId = null;
|
||||
}
|
||||
|
||||
public function toggleSelect(int $id): void
|
||||
{
|
||||
if (in_array($id, $this->selectedIds, true)) {
|
||||
$this->selectedIds = array_values(array_diff($this->selectedIds, [$id]));
|
||||
} else {
|
||||
$this->selectedIds[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
public function mergeSelected(): void
|
||||
{
|
||||
$ids = $this->selectedIdsInScope();
|
||||
|
||||
if (count($ids) < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(TicketService::class)->merge($ids);
|
||||
$this->selectedIds = [];
|
||||
}
|
||||
|
||||
public function requestDeleteSelected(): void
|
||||
{
|
||||
if (count($this->selectedIds) < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->pendingDeleteSelected = true;
|
||||
}
|
||||
|
||||
public function cancelDeleteSelected(): void
|
||||
{
|
||||
$this->pendingDeleteSelected = false;
|
||||
}
|
||||
|
||||
public function confirmDeleteSelected(): void
|
||||
{
|
||||
Ticket::query()->visibleToOperator(Auth::user())->whereIn('id', $this->selectedIds)->delete();
|
||||
$this->selectedIds = [];
|
||||
$this->pendingDeleteSelected = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards against a crafted client-side call selecting ticket ids the
|
||||
* operator wouldn't otherwise see, since selectedIds is just a public
|
||||
* Livewire property.
|
||||
*/
|
||||
protected function selectedIdsInScope(): array
|
||||
{
|
||||
return Ticket::query()->visibleToOperator(Auth::user())->whereIn('id', $this->selectedIds)->pluck('id')->all();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.operator.queue');
|
||||
}
|
||||
}
|
||||
394
src/app/Livewire/Operator/Stats.php
Normal file
394
src/app/Livewire/Operator/Stats.php
Normal file
@@ -0,0 +1,394 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Operator;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Priority;
|
||||
use App\Models\SlaRule;
|
||||
use App\Models\Status;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
class Stats extends Component
|
||||
{
|
||||
#[Url]
|
||||
public string $range = '30d';
|
||||
|
||||
#[Url]
|
||||
public ?string $from = null;
|
||||
|
||||
#[Url]
|
||||
public ?string $to = null;
|
||||
|
||||
#[Url]
|
||||
public string $filterTeam = 'all';
|
||||
|
||||
#[Url]
|
||||
public string $filterPriority = 'all';
|
||||
|
||||
#[Url]
|
||||
public string $filterCategory = 'all';
|
||||
|
||||
#[Url]
|
||||
public string $filterAssignee = 'all';
|
||||
|
||||
/**
|
||||
* Non-admin operators only ever see their own teams — same scoping as
|
||||
* Queue's team tabs, so the filter options never expose data the
|
||||
* visibleToOperator() query would filter back out anyway.
|
||||
*/
|
||||
#[Computed]
|
||||
public function teams()
|
||||
{
|
||||
$query = Team::query();
|
||||
|
||||
if (! Auth::user()->isAdmin()) {
|
||||
$query->whereHas('members', fn ($q) => $q->where('users.id', Auth::id()));
|
||||
}
|
||||
|
||||
return $query->orderBy('name')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function priorities()
|
||||
{
|
||||
return Priority::query()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function categories()
|
||||
{
|
||||
return Category::query()->orderBy('name')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function operators()
|
||||
{
|
||||
$query = User::query()->withRole('operator');
|
||||
|
||||
if (! Auth::user()->isAdmin()) {
|
||||
$teamIds = $this->teams->pluck('id');
|
||||
$query->whereHas('teams', fn ($q) => $q->whereIn('teams.id', $teamIds));
|
||||
}
|
||||
|
||||
return $query->orderBy('name')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{from: ?Carbon, to: ?Carbon}
|
||||
*/
|
||||
protected function bounds(): array
|
||||
{
|
||||
$now = now();
|
||||
|
||||
return match ($this->range) {
|
||||
'today' => ['from' => $now->clone()->startOfDay(), 'to' => $now],
|
||||
'7d' => ['from' => $now->clone()->subDays(6)->startOfDay(), 'to' => $now],
|
||||
'30d' => ['from' => $now->clone()->subDays(29)->startOfDay(), 'to' => $now],
|
||||
'90d' => ['from' => $now->clone()->subDays(89)->startOfDay(), 'to' => $now],
|
||||
'custom' => [
|
||||
'from' => $this->from ? Carbon::parse($this->from)->startOfDay() : $now->clone()->subDays(29)->startOfDay(),
|
||||
'to' => $this->to ? Carbon::parse($this->to)->endOfDay() : $now,
|
||||
],
|
||||
default => ['from' => null, 'to' => null],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared, fully-filtered ticket scope every stat below reads from —
|
||||
* cloned per terminal call (count/get/pluck) since none of those mutate
|
||||
* the underlying where clauses, only cloning avoids one query's
|
||||
* ->select()/->groupBy() leaking into the next.
|
||||
*/
|
||||
#[Computed]
|
||||
public function baseQuery()
|
||||
{
|
||||
$query = Ticket::query()->visibleToOperator(Auth::user());
|
||||
$bounds = $this->bounds();
|
||||
|
||||
if ($bounds['from']) {
|
||||
$query->where('tickets.created_at', '>=', $bounds['from']);
|
||||
}
|
||||
if ($bounds['to']) {
|
||||
$query->where('tickets.created_at', '<=', $bounds['to']);
|
||||
}
|
||||
if ($this->filterTeam !== 'all') {
|
||||
$query->where('tickets.team_id', $this->filterTeam);
|
||||
}
|
||||
if ($this->filterPriority !== 'all') {
|
||||
$query->where('tickets.priority_key', $this->filterPriority);
|
||||
}
|
||||
if ($this->filterCategory !== 'all') {
|
||||
$query->whereHas('subcategory', fn ($q) => $q->where('category_id', $this->filterCategory));
|
||||
}
|
||||
if ($this->filterAssignee === 'unassigned') {
|
||||
$query->whereNull('tickets.assignee_id');
|
||||
} elseif ($this->filterAssignee !== 'all') {
|
||||
$query->where('tickets.assignee_id', $this->filterAssignee);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function kpis(): array
|
||||
{
|
||||
$total = (clone $this->baseQuery)->count();
|
||||
$closedKeys = Status::closedKeys();
|
||||
$closed = (clone $this->baseQuery)->whereIn('tickets.status_key', $closedKeys)->count();
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'open' => $total - $closed,
|
||||
'closed' => $closed,
|
||||
'closedPct' => $total > 0 ? round($closed / $total * 100, 1) : null,
|
||||
'avgFirstResponseHours' => $this->avgFirstResponseHours(),
|
||||
'avgResolutionHours' => $this->avgResolutionHours($closedKeys),
|
||||
'sla' => $this->slaBreachStats($closedKeys),
|
||||
];
|
||||
}
|
||||
|
||||
protected function avgFirstResponseHours(): ?float
|
||||
{
|
||||
$ids = (clone $this->baseQuery)->pluck('tickets.id');
|
||||
|
||||
if ($ids->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$firstReplies = DB::table('ticket_messages as tm')
|
||||
->join('ticket_message_authors as tma', 'tma.ticket_message_id', '=', 'tm.id')
|
||||
->join('roles as r', 'r.id', '=', 'tma.role_id')
|
||||
->where('r.key', 'operator')
|
||||
->whereIn('tm.ticket_id', $ids)
|
||||
->select('tm.ticket_id', DB::raw('MIN(tm.created_at) as first_reply'))
|
||||
->groupBy('tm.ticket_id')
|
||||
->pluck('first_reply', 'ticket_id');
|
||||
|
||||
if ($firstReplies->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$createdAts = Ticket::query()->whereIn('id', $firstReplies->keys())->pluck('created_at', 'id');
|
||||
|
||||
$minutes = $firstReplies->map(fn ($firstReply, $ticketId) => $createdAts[$ticketId]?->diffInMinutes(Carbon::parse($firstReply)))
|
||||
->filter(fn ($v) => $v !== null);
|
||||
|
||||
return $minutes->isEmpty() ? null : round($minutes->avg() / 60, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate: no dedicated "resolved_at" column exists, so a closed
|
||||
* ticket's updated_at (touched whenever its status changes — see
|
||||
* TicketService::setStatus()) stands in for when it was closed.
|
||||
*/
|
||||
protected function avgResolutionHours(array $closedKeys): ?float
|
||||
{
|
||||
$closed = (clone $this->baseQuery)->whereIn('tickets.status_key', $closedKeys)->get(['created_at', 'updated_at']);
|
||||
|
||||
if ($closed->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round($closed->avg(fn (Ticket $t) => $t->created_at->diffInMinutes($t->updated_at)) / 60, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* A ticket "breaches SLA" if its resolution deadline (priority's
|
||||
* sla_rules.resolution_mins, from creation) has passed — either already,
|
||||
* for a still-open ticket, or before it was closed (updated_at stands in
|
||||
* for the close time, same approximation as avgResolutionHours()).
|
||||
* Priorities with resolution_mins = 0 (e.g. "Brak") never breach.
|
||||
*/
|
||||
protected function slaBreachStats(array $closedKeys): array
|
||||
{
|
||||
$tickets = (clone $this->baseQuery)->get(['status_key', 'priority_key', 'created_at', 'updated_at']);
|
||||
|
||||
if ($tickets->isEmpty()) {
|
||||
return ['rate' => null, 'breached' => 0, 'total' => 0];
|
||||
}
|
||||
|
||||
$rules = SlaRule::query()->pluck('resolution_mins', 'priority_key');
|
||||
$now = now();
|
||||
|
||||
$breached = $tickets->filter(function (Ticket $t) use ($rules, $closedKeys, $now) {
|
||||
$mins = $rules[$t->priority_key] ?? 0;
|
||||
|
||||
if ($mins <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$deadline = $t->created_at->clone()->addMinutes($mins);
|
||||
|
||||
return in_array($t->status_key, $closedKeys, true)
|
||||
? $t->updated_at->isAfter($deadline)
|
||||
: $now->isAfter($deadline);
|
||||
})->count();
|
||||
|
||||
return [
|
||||
'rate' => round($breached / $tickets->count() * 100, 1),
|
||||
'breached' => $breached,
|
||||
'total' => $tickets->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The "closed" stage's color, reused for the trend chart's closed-tickets
|
||||
* strip so it visually matches the same status everywhere else in the
|
||||
* app — falls back to the secondary brand hue if no status is closed.
|
||||
*/
|
||||
#[Computed]
|
||||
public function closedColor(): string
|
||||
{
|
||||
return Status::query()->where('stage', 'closed')->value('color') ?? 'var(--color-accent-2)';
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function byStatus()
|
||||
{
|
||||
$counts = (clone $this->baseQuery)
|
||||
->select('status_key', DB::raw('count(*) as total'))
|
||||
->groupBy('status_key')
|
||||
->pluck('total', 'status_key');
|
||||
|
||||
return Status::query()->orderBy('sort_order')->get()->map(fn (Status $s) => [
|
||||
'label' => $s->label,
|
||||
'color' => $s->color,
|
||||
'count' => $counts[$s->key] ?? 0,
|
||||
])->values();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function byPriority()
|
||||
{
|
||||
$counts = (clone $this->baseQuery)
|
||||
->select('priority_key', DB::raw('count(*) as total'))
|
||||
->groupBy('priority_key')
|
||||
->pluck('total', 'priority_key');
|
||||
|
||||
return $this->priorities->map(fn (Priority $p) => [
|
||||
'label' => $p->label,
|
||||
'color' => $p->color,
|
||||
'count' => $counts[$p->key] ?? 0,
|
||||
])->values();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function byCategory()
|
||||
{
|
||||
return (clone $this->baseQuery)
|
||||
->whereNotNull('tickets.subcategory_id')
|
||||
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
|
||||
->join('categories', 'categories.id', '=', 'subcategories.category_id')
|
||||
->select('categories.name as label', DB::raw('count(*) as count'))
|
||||
->groupBy('categories.id', 'categories.name')
|
||||
->orderByDesc('count')
|
||||
->get()
|
||||
->map(fn ($row) => ['label' => $row->label, 'count' => (int) $row->count]);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function byTeam()
|
||||
{
|
||||
$counts = (clone $this->baseQuery)
|
||||
->select('team_id', DB::raw('count(*) as total'))
|
||||
->groupBy('team_id')
|
||||
->pluck('total', 'team_id');
|
||||
|
||||
$rows = $this->teams->map(fn (Team $t) => ['label' => $t->name, 'count' => $counts[$t->id] ?? 0])
|
||||
->sortByDesc('count')
|
||||
->values();
|
||||
|
||||
if ($counts->get(null, 0)) {
|
||||
$rows->push(['label' => 'Bez zespołu', 'count' => $counts->get(null)]);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function byAssignee()
|
||||
{
|
||||
$counts = (clone $this->baseQuery)
|
||||
->select('assignee_id', DB::raw('count(*) as total'))
|
||||
->groupBy('assignee_id')
|
||||
->pluck('total', 'assignee_id');
|
||||
|
||||
$rows = $this->operators->map(fn (User $u) => ['label' => $u->name, 'count' => $counts[$u->id] ?? 0])
|
||||
->filter(fn ($row) => $row['count'] > 0)
|
||||
->sortByDesc('count')
|
||||
->values();
|
||||
|
||||
if ($counts->get(null, 0)) {
|
||||
$rows->push(['label' => 'Nieprzypisane', 'count' => $counts->get(null)]);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily created-vs-closed volume, capped at the most recent 60 days so a
|
||||
* wide range (or "Cały okres") never renders an unreadably thin column
|
||||
* per day — the KPI tiles/breakdowns above still reflect the full range.
|
||||
*/
|
||||
#[Computed]
|
||||
public function trend(): array
|
||||
{
|
||||
$bounds = $this->bounds();
|
||||
$to = ($bounds['to'] ?? now())->clone()->startOfDay();
|
||||
$from = $bounds['from']?->clone()->startOfDay();
|
||||
|
||||
if (! $from) {
|
||||
$earliest = (clone $this->baseQuery)->min('tickets.created_at');
|
||||
$from = $earliest ? Carbon::parse($earliest)->startOfDay() : $to->clone()->subDays(29);
|
||||
}
|
||||
|
||||
$maxDays = 60;
|
||||
if ($from->diffInDays($to) + 1 > $maxDays) {
|
||||
$from = $to->clone()->subDays($maxDays - 1);
|
||||
}
|
||||
|
||||
$created = (clone $this->baseQuery)
|
||||
->selectRaw('DATE(tickets.created_at) as d, count(*) as total')
|
||||
->groupBy('d')->pluck('total', 'd');
|
||||
|
||||
$closedKeys = Status::closedKeys();
|
||||
$closed = (clone $this->baseQuery)
|
||||
->whereIn('tickets.status_key', $closedKeys)
|
||||
->selectRaw('DATE(tickets.updated_at) as d, count(*) as total')
|
||||
->groupBy('d')->pluck('total', 'd');
|
||||
|
||||
$days = [];
|
||||
$cursor = $from->clone();
|
||||
|
||||
while ($cursor->lte($to)) {
|
||||
$key = $cursor->format('Y-m-d');
|
||||
$days[] = [
|
||||
'date' => $key,
|
||||
'label' => $cursor->translatedFormat('d.m'),
|
||||
'created' => (int) ($created[$key] ?? 0),
|
||||
'closed' => (int) ($closed[$key] ?? 0),
|
||||
];
|
||||
$cursor->addDay();
|
||||
}
|
||||
|
||||
return $days;
|
||||
}
|
||||
|
||||
public function setRange(string $range): void
|
||||
{
|
||||
$this->range = $range;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.operator.stats');
|
||||
}
|
||||
}
|
||||
503
src/app/Livewire/Operator/TicketShow.php
Normal file
503
src/app/Livewire/Operator/TicketShow.php
Normal file
@@ -0,0 +1,503 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Operator;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Priority;
|
||||
use App\Models\ReplyQuickAction;
|
||||
use App\Models\ResponseTemplate;
|
||||
use App\Models\Status;
|
||||
use App\Models\Subcategory;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketMessage;
|
||||
use App\Models\User;
|
||||
use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class TicketShow extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public Ticket $ticket;
|
||||
|
||||
public string $reply = '';
|
||||
|
||||
public array $replyAttachments = [];
|
||||
|
||||
public string $templateId = '';
|
||||
|
||||
public bool $addingNote = false;
|
||||
|
||||
public string $noteDraft = '';
|
||||
|
||||
public array $noteAttachments = [];
|
||||
|
||||
public ?int $editingNoteId = null;
|
||||
|
||||
public string $editingNoteDraft = '';
|
||||
|
||||
public ?int $editingMessageId = null;
|
||||
|
||||
public string $editingMessageDraft = '';
|
||||
|
||||
public ?int $pendingDeleteMessageId = null;
|
||||
|
||||
public bool $pendingDeleteTicket = false;
|
||||
|
||||
public bool $editingDetails = false;
|
||||
|
||||
public array $editDetailsForm = ['subject' => '', 'body' => '', 'categoryId' => '', 'subcategoryId' => '', 'customValues' => []];
|
||||
|
||||
public bool $editingReporter = false;
|
||||
|
||||
public string $editReporterCustomerId = '';
|
||||
|
||||
public bool $editingTimer = false;
|
||||
|
||||
public string $editTimerHours = '0';
|
||||
|
||||
public string $editTimerMinutes = '0';
|
||||
|
||||
public string $editTimerSeconds = '0';
|
||||
|
||||
public function mount(Ticket $ticket): void
|
||||
{
|
||||
abort_unless($ticket->isVisibleToOperator(Auth::user()), 403);
|
||||
|
||||
$this->ticket = $ticket;
|
||||
|
||||
// The timer tracks only actual time spent with the ticket open in
|
||||
// the browser (see stopTimer() calls wired to leaving the page in
|
||||
// ticket-show.blade.php) — so every open resumes it, not just the
|
||||
// very first one.
|
||||
$this->ticket->resumeTimer();
|
||||
}
|
||||
|
||||
// -------- time tracking --------
|
||||
|
||||
public function stopTimer(): void
|
||||
{
|
||||
$this->ticket->stopTimer();
|
||||
}
|
||||
|
||||
public function resumeTimer(): void
|
||||
{
|
||||
$this->ticket->resumeTimer();
|
||||
}
|
||||
|
||||
public function resetTimer(): void
|
||||
{
|
||||
$this->ticket->resetTimer();
|
||||
}
|
||||
|
||||
public function startEditTimer(): void
|
||||
{
|
||||
$seconds = $this->ticket->timerElapsedSeconds();
|
||||
$this->editTimerHours = (string) intdiv($seconds, 3600);
|
||||
$this->editTimerMinutes = (string) intdiv($seconds % 3600, 60);
|
||||
$this->editTimerSeconds = (string) ($seconds % 60);
|
||||
$this->editingTimer = true;
|
||||
}
|
||||
|
||||
public function cancelEditTimer(): void
|
||||
{
|
||||
$this->editingTimer = false;
|
||||
}
|
||||
|
||||
public function saveEditTimer(): void
|
||||
{
|
||||
$seconds = ((int) $this->editTimerHours) * 3600
|
||||
+ ((int) $this->editTimerMinutes) * 60
|
||||
+ ((int) $this->editTimerSeconds);
|
||||
|
||||
$this->ticket->setTimeSpent($seconds);
|
||||
$this->editingTimer = false;
|
||||
}
|
||||
|
||||
public function updatedReplyAttachments(): void
|
||||
{
|
||||
if (! $this->replyAttachments) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($error = Settings::validateAttachments($this->replyAttachments)) {
|
||||
$this->replyAttachments = [];
|
||||
$this->addError('replyAttachments', $error);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeReplyAttachment(int $index): void
|
||||
{
|
||||
unset($this->replyAttachments[$index]);
|
||||
$this->replyAttachments = array_values($this->replyAttachments);
|
||||
}
|
||||
|
||||
public function updatedNoteAttachments(): void
|
||||
{
|
||||
if (! $this->noteAttachments) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($error = Settings::validateAttachments($this->noteAttachments)) {
|
||||
$this->noteAttachments = [];
|
||||
$this->addError('noteAttachments', $error);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeNoteAttachment(int $index): void
|
||||
{
|
||||
unset($this->noteAttachments[$index]);
|
||||
$this->noteAttachments = array_values($this->noteAttachments);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function publicMessages()
|
||||
{
|
||||
return $this->ticket->publicMessages()->with(['author', 'authorLink.role', 'attachments'])->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function internalMessages()
|
||||
{
|
||||
return $this->ticket->internalMessages()->with(['author', 'authorLink.role', 'attachments'])->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function statuses()
|
||||
{
|
||||
return Status::query()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function priorities()
|
||||
{
|
||||
return Priority::query()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function categories()
|
||||
{
|
||||
return Category::query()->with('subcategories')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-admin operator can only reassign a ticket to one of their own
|
||||
* teams (mirrors the visibility scoping in Operator\Queue).
|
||||
*/
|
||||
#[Computed]
|
||||
public function teams()
|
||||
{
|
||||
$query = Team::query();
|
||||
|
||||
if (! Auth::user()->isAdmin()) {
|
||||
$query->whereHas('members', fn ($q) => $q->where('users.id', Auth::id()));
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function operators()
|
||||
{
|
||||
return User::query()->withRole('operator')->orderBy('name')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function clients()
|
||||
{
|
||||
return User::query()->withRole('client')->orderBy('name')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function responseTemplates()
|
||||
{
|
||||
return ResponseTemplate::query()->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function replyQuickActions()
|
||||
{
|
||||
return ReplyQuickAction::query()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function editDetailsSubcategoryOptions()
|
||||
{
|
||||
if (! $this->editDetailsForm['categoryId']) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Subcategory::query()->where('category_id', $this->editDetailsForm['categoryId'])->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function editDetailsSelectedSubcategory(): ?Subcategory
|
||||
{
|
||||
return $this->editDetailsForm['subcategoryId']
|
||||
? Subcategory::query()->with('customFields')->find($this->editDetailsForm['subcategoryId'])
|
||||
: null;
|
||||
}
|
||||
|
||||
// -------- status / priority / assignee / team --------
|
||||
|
||||
public function setStatus(string $key): void
|
||||
{
|
||||
app(TicketService::class)->setStatus($this->ticket, $key);
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
public function setPriority(string $key): void
|
||||
{
|
||||
app(TicketService::class)->setPriority($this->ticket, $key);
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
public function setAssignee(string $id): void
|
||||
{
|
||||
app(TicketService::class)->setAssignee($this->ticket, $id ? User::query()->find($id) : null);
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
public function assignToMe(): void
|
||||
{
|
||||
app(TicketService::class)->setAssignee($this->ticket, Auth::user());
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
public function setTeam(string $id): void
|
||||
{
|
||||
app(TicketService::class)->setTeam($this->ticket, $id ? Team::query()->find($id) : null);
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
// -------- reporter --------
|
||||
|
||||
public function startEditReporter(): void
|
||||
{
|
||||
$this->editingReporter = true;
|
||||
$this->editReporterCustomerId = (string) $this->ticket->customer_id;
|
||||
}
|
||||
|
||||
public function cancelEditReporter(): void
|
||||
{
|
||||
$this->editingReporter = false;
|
||||
}
|
||||
|
||||
public function saveReporter(): void
|
||||
{
|
||||
$customer = User::query()->find($this->editReporterCustomerId);
|
||||
|
||||
if ($customer) {
|
||||
app(TicketService::class)->setReporter($this->ticket, $customer);
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
$this->editingReporter = false;
|
||||
}
|
||||
|
||||
// -------- details --------
|
||||
|
||||
public function toggleEditDetails(): void
|
||||
{
|
||||
$this->editingDetails = true;
|
||||
$this->editDetailsForm = [
|
||||
'subject' => $this->ticket->subject,
|
||||
'body' => $this->ticket->body,
|
||||
'categoryId' => $this->ticket->subcategory?->category_id,
|
||||
'subcategoryId' => $this->ticket->subcategory_id,
|
||||
'customValues' => $this->ticket->custom_fields ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
public function cancelEditDetails(): void
|
||||
{
|
||||
$this->editingDetails = false;
|
||||
}
|
||||
|
||||
public function updatedEditDetailsForm($value, $key): void
|
||||
{
|
||||
if ($key === 'categoryId') {
|
||||
$this->editDetailsForm['subcategoryId'] = '';
|
||||
}
|
||||
}
|
||||
|
||||
public function saveDetails(): void
|
||||
{
|
||||
app(TicketService::class)->updateDetails($this->ticket, [
|
||||
'subject' => $this->editDetailsForm['subject'],
|
||||
'body' => $this->editDetailsForm['body'],
|
||||
'subcategory_id' => $this->editDetailsForm['subcategoryId'] ?: null,
|
||||
'custom_values' => $this->editDetailsForm['customValues'],
|
||||
]);
|
||||
$this->ticket->refresh();
|
||||
$this->editingDetails = false;
|
||||
}
|
||||
|
||||
// -------- internal notes --------
|
||||
|
||||
public function startAddNote(): void
|
||||
{
|
||||
$this->addingNote = true;
|
||||
}
|
||||
|
||||
public function cancelAddNote(): void
|
||||
{
|
||||
$this->addingNote = false;
|
||||
$this->reset(['noteDraft', 'noteAttachments']);
|
||||
}
|
||||
|
||||
public function addNote(): void
|
||||
{
|
||||
if (! trim($this->noteDraft)) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(TicketService::class)->operatorNote($this->ticket, Auth::user(), $this->noteDraft, $this->noteAttachments);
|
||||
$this->reset(['noteDraft', 'noteAttachments']);
|
||||
$this->addingNote = false;
|
||||
unset($this->internalMessages);
|
||||
}
|
||||
|
||||
public function startEditNote(int $id, string $body): void
|
||||
{
|
||||
$this->editingNoteId = $id;
|
||||
$this->editingNoteDraft = $body;
|
||||
}
|
||||
|
||||
public function cancelEditNote(): void
|
||||
{
|
||||
$this->editingNoteId = null;
|
||||
$this->editingNoteDraft = '';
|
||||
}
|
||||
|
||||
public function saveEditNote(): void
|
||||
{
|
||||
$message = TicketMessage::query()->findOrFail($this->editingNoteId);
|
||||
$message->update(['body' => $this->editingNoteDraft, 'edited' => true]);
|
||||
$this->cancelEditNote();
|
||||
unset($this->internalMessages);
|
||||
}
|
||||
|
||||
// -------- public replies --------
|
||||
|
||||
public function setTemplate(string $id): void
|
||||
{
|
||||
$this->templateId = $id;
|
||||
$template = ResponseTemplate::query()->find($id);
|
||||
|
||||
if ($template) {
|
||||
$this->reply = $template->body;
|
||||
}
|
||||
}
|
||||
|
||||
protected function resetReply(): void
|
||||
{
|
||||
$this->reply = '';
|
||||
$this->replyAttachments = [];
|
||||
$this->templateId = '';
|
||||
unset($this->publicMessages);
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
public function sendReply(): void
|
||||
{
|
||||
if (! trim($this->reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(TicketService::class)->operatorReply($this->ticket, Auth::user(), $this->reply, attachments: $this->replyAttachments);
|
||||
$this->resetReply();
|
||||
}
|
||||
|
||||
public function sendAndTransition(int $quickActionId): void
|
||||
{
|
||||
if (! trim($this->reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$status = ReplyQuickAction::query()->find($quickActionId)?->status_key;
|
||||
|
||||
app(TicketService::class)->operatorReply($this->ticket, Auth::user(), $this->reply, $status, $this->replyAttachments);
|
||||
$this->resetReply();
|
||||
}
|
||||
|
||||
// -------- message edit/delete (shared by public + internal bubbles) --------
|
||||
|
||||
public function startEditMessage(int $id, string $body): void
|
||||
{
|
||||
$message = TicketMessage::query()->findOrFail($id);
|
||||
abort_unless($message->ticket_id === $this->ticket->id && $message->role === 'operator', 403);
|
||||
|
||||
$this->editingMessageId = $id;
|
||||
$this->editingMessageDraft = $body;
|
||||
}
|
||||
|
||||
public function cancelEditMessage(): void
|
||||
{
|
||||
$this->editingMessageId = null;
|
||||
$this->editingMessageDraft = '';
|
||||
}
|
||||
|
||||
public function saveEditMessage(): void
|
||||
{
|
||||
$message = TicketMessage::query()->findOrFail($this->editingMessageId);
|
||||
abort_unless($message->ticket_id === $this->ticket->id && $message->role === 'operator', 403);
|
||||
|
||||
$message->update(['body' => $this->editingMessageDraft, 'edited' => true]);
|
||||
$this->cancelEditMessage();
|
||||
unset($this->publicMessages, $this->internalMessages);
|
||||
}
|
||||
|
||||
public function requestDeleteMessage(int $id): void
|
||||
{
|
||||
$this->pendingDeleteMessageId = $id;
|
||||
}
|
||||
|
||||
public function cancelDeleteMessage(): void
|
||||
{
|
||||
$this->pendingDeleteMessageId = null;
|
||||
}
|
||||
|
||||
public function confirmDeleteMessage(): void
|
||||
{
|
||||
$message = TicketMessage::query()->findOrFail($this->pendingDeleteMessageId);
|
||||
abort_unless($message->ticket_id === $this->ticket->id && $message->role === 'operator', 403);
|
||||
|
||||
$message->delete();
|
||||
$this->pendingDeleteMessageId = null;
|
||||
unset($this->publicMessages, $this->internalMessages);
|
||||
}
|
||||
|
||||
// -------- delete ticket --------
|
||||
|
||||
public function requestDeleteTicket(): void
|
||||
{
|
||||
$this->pendingDeleteTicket = true;
|
||||
}
|
||||
|
||||
public function cancelDeleteTicket(): void
|
||||
{
|
||||
$this->pendingDeleteTicket = false;
|
||||
}
|
||||
|
||||
public function confirmDeleteTicket(): void
|
||||
{
|
||||
$this->ticket->delete();
|
||||
$this->redirect(route('operator.queue'), navigate: true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
// Checkpoints the running timer into the stored total on every
|
||||
// action (any wire:click/change re-renders the component), so
|
||||
// progress is saved incrementally rather than only on explicit stop.
|
||||
$this->ticket->flushTimer();
|
||||
|
||||
return view('livewire.operator.ticket-show');
|
||||
}
|
||||
}
|
||||
42
src/app/Models/ApiClient.php
Normal file
42
src/app/Models/ApiClient.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ApiClientFactory;
|
||||
use Illuminate\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
/**
|
||||
* An API key's owning entity — not a User (see decision in the API plan: keys
|
||||
* represent independent integrations, not people). Still needs to implement
|
||||
* Authenticatable so Sanctum's guard can set it as $request->user() on
|
||||
* routes protected by auth:sanctum.
|
||||
*/
|
||||
#[Fillable(['name', 'description', 'created_by', 'revoked_at'])]
|
||||
class ApiClient extends Model implements AuthenticatableContract
|
||||
{
|
||||
/** @use HasFactory<ApiClientFactory> */
|
||||
use Authenticatable, HasApiTokens, HasFactory;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'revoked_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function isRevoked(): bool
|
||||
{
|
||||
return $this->revoked_at !== null;
|
||||
}
|
||||
}
|
||||
16
src/app/Models/Category.php
Normal file
16
src/app/Models/Category.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['name', 'description'])]
|
||||
class Category extends Model
|
||||
{
|
||||
public function subcategories(): HasMany
|
||||
{
|
||||
return $this->hasMany(Subcategory::class);
|
||||
}
|
||||
}
|
||||
38
src/app/Models/CustomField.php
Normal file
38
src/app/Models/CustomField.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
#[Fillable(['label', 'type', 'required', 'options', 'sort_order'])]
|
||||
class CustomField extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'required' => 'boolean',
|
||||
'options' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function subcategories(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Subcategory::class, 'custom_field_subcategory')
|
||||
->withPivot('position');
|
||||
}
|
||||
|
||||
public function typeLabel(): string
|
||||
{
|
||||
return match ($this->type) {
|
||||
'text' => 'Tekst krótki',
|
||||
'textarea' => 'Tekst długi',
|
||||
'select' => 'Lista wyboru',
|
||||
'checkbox' => 'Checkbox',
|
||||
'date' => 'Data',
|
||||
'number' => 'Liczba',
|
||||
default => $this->type,
|
||||
};
|
||||
}
|
||||
}
|
||||
26
src/app/Models/EmailTemplate.php
Normal file
26
src/app/Models/EmailTemplate.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['key', 'name', 'trigger_label', 'subject', 'body'])]
|
||||
class EmailTemplate extends Model
|
||||
{
|
||||
public function render(array $placeholders): array
|
||||
{
|
||||
$replace = function (string $text) use ($placeholders): string {
|
||||
foreach ($placeholders as $key => $value) {
|
||||
$text = str_replace('{'.$key.'}', (string) $value, $text);
|
||||
}
|
||||
|
||||
return $text;
|
||||
};
|
||||
|
||||
return [
|
||||
'subject' => $replace($this->subject),
|
||||
'body' => $replace($this->body),
|
||||
];
|
||||
}
|
||||
}
|
||||
21
src/app/Models/NotificationSetting.php
Normal file
21
src/app/Models/NotificationSetting.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['trigger_key', 'trigger_label', 'enabled', 'recipient', 'email_template_id'])]
|
||||
class NotificationSetting extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['enabled' => 'boolean'];
|
||||
}
|
||||
|
||||
public function emailTemplate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EmailTemplate::class);
|
||||
}
|
||||
}
|
||||
32
src/app/Models/Priority.php
Normal file
32
src/app/Models/Priority.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
#[Fillable(['key', 'label', 'color', 'sort_order'])]
|
||||
class Priority extends Model
|
||||
{
|
||||
protected $primaryKey = 'key';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
public function slaRule(): HasOne
|
||||
{
|
||||
return $this->hasOne(SlaRule::class, 'priority_key');
|
||||
}
|
||||
|
||||
public static function labelFor(string $key): string
|
||||
{
|
||||
return static::query()->find($key)?->label ?? $key;
|
||||
}
|
||||
|
||||
public static function colorFor(string $key): string
|
||||
{
|
||||
return static::query()->find($key)?->color ?? '#75798c';
|
||||
}
|
||||
}
|
||||
15
src/app/Models/ReplyQuickAction.php
Normal file
15
src/app/Models/ReplyQuickAction.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['label', 'status_key', 'sort_order'])]
|
||||
class ReplyQuickAction extends Model
|
||||
{
|
||||
public function statusLabel(): string
|
||||
{
|
||||
return $this->status_key ? Status::labelFor($this->status_key) : 'Nie zmieniaj statusu';
|
||||
}
|
||||
}
|
||||
12
src/app/Models/ResponseTemplate.php
Normal file
12
src/app/Models/ResponseTemplate.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['label', 'body'])]
|
||||
class ResponseTemplate extends Model
|
||||
{
|
||||
//
|
||||
}
|
||||
16
src/app/Models/Role.php
Normal file
16
src/app/Models/Role.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
#[Fillable(['key', 'label'])]
|
||||
class Role extends Model
|
||||
{
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class);
|
||||
}
|
||||
}
|
||||
16
src/app/Models/Setting.php
Normal file
16
src/app/Models/Setting.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['key', 'value'])]
|
||||
class Setting extends Model
|
||||
{
|
||||
protected $primaryKey = 'key';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
public $incrementing = false;
|
||||
}
|
||||
16
src/app/Models/SlaRule.php
Normal file
16
src/app/Models/SlaRule.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['priority_key', 'response_mins', 'resolution_mins'])]
|
||||
class SlaRule extends Model
|
||||
{
|
||||
public function priority(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Priority::class, 'priority_key');
|
||||
}
|
||||
}
|
||||
46
src/app/Models/Status.php
Normal file
46
src/app/Models/Status.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['key', 'label', 'color', 'sort_order', 'stage', 'locked'])]
|
||||
class Status extends Model
|
||||
{
|
||||
protected $primaryKey = 'key';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['locked' => 'boolean'];
|
||||
}
|
||||
|
||||
public static function labelFor(string $key): string
|
||||
{
|
||||
return static::query()->find($key)?->label ?? $key;
|
||||
}
|
||||
|
||||
public static function colorFor(string $key): string
|
||||
{
|
||||
return static::query()->find($key)?->color ?? '#75798c';
|
||||
}
|
||||
|
||||
public static function stageFor(string $key): string
|
||||
{
|
||||
return static::query()->find($key)?->stage ?? 'open';
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys whose stage is "closed" — in practice always just ['closed'],
|
||||
* since Admin can only ever add new statuses into the "open" stage, but
|
||||
* this stays generic instead of hardcoding that assumption everywhere.
|
||||
*/
|
||||
public static function closedKeys(): array
|
||||
{
|
||||
return static::query()->where('stage', 'closed')->pluck('key')->all();
|
||||
}
|
||||
}
|
||||
40
src/app/Models/Subcategory.php
Normal file
40
src/app/Models/Subcategory.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['category_id', 'name', 'description', 'default_priority_key'])]
|
||||
class Subcategory extends Model
|
||||
{
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function customFields(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(CustomField::class, 'custom_field_subcategory')
|
||||
->withPivot('position')
|
||||
->orderBy('custom_field_subcategory.position');
|
||||
}
|
||||
|
||||
public function teams(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Team::class, 'team_subcategory');
|
||||
}
|
||||
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class);
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return $this->category->name.' / '.$this->name;
|
||||
}
|
||||
}
|
||||
27
src/app/Models/Team.php
Normal file
27
src/app/Models/Team.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['name'])]
|
||||
class Team extends Model
|
||||
{
|
||||
public function members(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'team_user');
|
||||
}
|
||||
|
||||
public function subcategories(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Subcategory::class, 'team_subcategory');
|
||||
}
|
||||
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class);
|
||||
}
|
||||
}
|
||||
337
src/app/Models/Ticket.php
Normal file
337
src/app/Models/Ticket.php
Normal file
@@ -0,0 +1,337 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
#[Fillable([
|
||||
'number', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body',
|
||||
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id',
|
||||
'sla_notified_at', 'time_spent_seconds', 'timer_started_at', 'created_at', 'updated_at',
|
||||
])]
|
||||
class Ticket extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'custom_fields' => 'array',
|
||||
'sla_notified_at' => 'datetime',
|
||||
'time_spent_seconds' => 'integer',
|
||||
'timer_started_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function customer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'customer_id');
|
||||
}
|
||||
|
||||
public function assignee(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'assignee_id');
|
||||
}
|
||||
|
||||
public function team(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function apiClient(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ApiClient::class);
|
||||
}
|
||||
|
||||
public function subcategory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Subcategory::class);
|
||||
}
|
||||
|
||||
public function status(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Status::class, 'status_key');
|
||||
}
|
||||
|
||||
public function priority(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Priority::class, 'priority_key');
|
||||
}
|
||||
|
||||
public function messages(): HasMany
|
||||
{
|
||||
return $this->hasMany(TicketMessage::class)->orderBy('created_at');
|
||||
}
|
||||
|
||||
public function publicMessages(): HasMany
|
||||
{
|
||||
return $this->messages()->where('internal', false);
|
||||
}
|
||||
|
||||
public function internalMessages(): HasMany
|
||||
{
|
||||
return $this->messages()->where('internal', true);
|
||||
}
|
||||
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(TicketAttachment::class);
|
||||
}
|
||||
|
||||
public function histories(): HasMany
|
||||
{
|
||||
return $this->hasMany(TicketHistory::class)->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
public static function nextNumber(): string
|
||||
{
|
||||
$max = static::query()->pluck('number')->map(fn ($n) => (int) $n)->max();
|
||||
|
||||
return (string) (($max ?: 1000) + 1);
|
||||
}
|
||||
|
||||
public function categoryLabel(): string
|
||||
{
|
||||
return $this->subcategory?->label() ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-admin operators only see tickets belonging to one of their own
|
||||
* teams (or not yet routed to any team), plus anything assigned to them
|
||||
* personally regardless of team. Admins see everything.
|
||||
*/
|
||||
public function scopeVisibleToOperator(Builder $query, User $user): Builder
|
||||
{
|
||||
if ($user->isAdmin()) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$teamIds = $user->teams->pluck('id')->all();
|
||||
|
||||
return $query->where(function ($q) use ($teamIds, $user) {
|
||||
$q->whereNull('team_id')
|
||||
->orWhereIn('team_id', $teamIds)
|
||||
->orWhere('assignee_id', $user->id);
|
||||
});
|
||||
}
|
||||
|
||||
public function isVisibleToOperator(User $user): bool
|
||||
{
|
||||
if ($user->isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->team_id === null
|
||||
|| $user->teams->pluck('id')->contains($this->team_id)
|
||||
|| $this->assignee_id === $user->id;
|
||||
}
|
||||
|
||||
public function addHistory(string $text): TicketHistory
|
||||
{
|
||||
return $this->histories()->create(['text' => $text, 'created_at' => now()]);
|
||||
}
|
||||
|
||||
public function statusLabel(): string
|
||||
{
|
||||
return Status::labelFor($this->status_key);
|
||||
}
|
||||
|
||||
public function priorityLabel(): string
|
||||
{
|
||||
return Priority::labelFor($this->priority_key);
|
||||
}
|
||||
|
||||
public function statusStyle(): string
|
||||
{
|
||||
return static::tagStyleFromColor(Status::colorFor($this->status_key));
|
||||
}
|
||||
|
||||
public function priorityStyle(): string
|
||||
{
|
||||
return static::tagStyleFromColor(Priority::colorFor($this->priority_key));
|
||||
}
|
||||
|
||||
public static function tagStyleFromColor(string $hex): string
|
||||
{
|
||||
return 'display:inline-flex;align-items:center;font-size:11px;letter-spacing:0.02em;padding:3px 10px;border-radius:6px;'.
|
||||
"background:color-mix(in srgb, {$hex} 20%, transparent);color:{$hex};".
|
||||
"border:1px solid color-mix(in srgb, {$hex} 45%, transparent)";
|
||||
}
|
||||
|
||||
public function isClosed(): bool
|
||||
{
|
||||
return Status::stageFor($this->status_key) === 'closed';
|
||||
}
|
||||
|
||||
/**
|
||||
* A resolution time of 0 minutes means "no SLA" for that priority, not
|
||||
* "due instantly" — such tickets never count down and never breach.
|
||||
*/
|
||||
protected function resolutionDeadline(): ?Carbon
|
||||
{
|
||||
$rule = $this->priority?->slaRule;
|
||||
|
||||
return $rule && $rule->resolution_mins > 0
|
||||
? $this->created_at->clone()->addMinutes($rule->resolution_mins)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the SLA resolution deadline has already passed — used by the
|
||||
* scheduled SLA-breach check, kept separate from slaInfo() so it doesn't
|
||||
* depend on parsing that method's display text.
|
||||
*/
|
||||
public function isOverdue(): bool
|
||||
{
|
||||
if ($this->isClosed()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$deadline = $this->resolutionDeadline();
|
||||
|
||||
return $deadline !== null && now()->isAfter($deadline);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the design prototype's slaInfo(): overdue / remaining-hours / closed.
|
||||
*/
|
||||
public function slaInfo(): array
|
||||
{
|
||||
if ($this->isClosed()) {
|
||||
return ['text' => 'Zamknięte', 'short' => '—', 'cls' => 'tag tag-neutral'];
|
||||
}
|
||||
|
||||
$rule = $this->priority?->slaRule;
|
||||
$deadline = $this->resolutionDeadline();
|
||||
|
||||
if (! $rule || $deadline === null) {
|
||||
return ['text' => 'Brak SLA', 'short' => 'Brak SLA', 'cls' => 'tag tag-neutral'];
|
||||
}
|
||||
|
||||
$diffMinutes = now()->diffInMinutes($deadline, false);
|
||||
|
||||
if ($diffMinutes < 0) {
|
||||
return ['text' => 'Przekroczono SLA', 'short' => 'Przekroczono', 'cls' => 'tag tag-accent'];
|
||||
}
|
||||
|
||||
$hrs = (int) round($diffMinutes / 60);
|
||||
$warn = $diffMinutes < ($rule->resolution_mins * 0.25);
|
||||
|
||||
return [
|
||||
'text' => "Pozostało {$hrs} godz.",
|
||||
'short' => "{$hrs} godz.",
|
||||
'cls' => $warn ? 'tag tag-outline' : 'tag tag-neutral',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Total seconds an operator has spent with this ticket's detail view
|
||||
* open, including the currently running segment (if the timer is active).
|
||||
*/
|
||||
public function timerElapsedSeconds(): int
|
||||
{
|
||||
$running = $this->timer_started_at ? $this->secondsSinceTimerStarted() : 0;
|
||||
|
||||
return $this->time_spent_seconds + $running;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole seconds between timer_started_at and now, via raw timestamp
|
||||
* subtraction rather than diffInSeconds() — Carbon 3 made the sign and
|
||||
* float-vs-int return type of diffInSeconds() depend on argument order,
|
||||
* which is easy to get backwards.
|
||||
*/
|
||||
protected function secondsSinceTimerStarted(): int
|
||||
{
|
||||
return max(0, now()->getTimestamp() - $this->timer_started_at->getTimestamp());
|
||||
}
|
||||
|
||||
public function timeSpentLabel(): string
|
||||
{
|
||||
$seconds = $this->timerElapsedSeconds();
|
||||
$hrs = intdiv($seconds, 3600);
|
||||
$mins = intdiv($seconds % 3600, 60);
|
||||
|
||||
return $hrs > 0 ? "{$hrs} godz. {$mins} min." : "{$mins} min.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkpoints the running segment into the stored total without pausing
|
||||
* — called on every operator action so progress survives even if the
|
||||
* ticket is never explicitly stopped.
|
||||
*/
|
||||
public function flushTimer(): void
|
||||
{
|
||||
if ($this->timer_started_at) {
|
||||
$this->update([
|
||||
'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(),
|
||||
'timer_started_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function stopTimer(): void
|
||||
{
|
||||
if ($this->timer_started_at) {
|
||||
$this->update([
|
||||
'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(),
|
||||
'timer_started_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function resumeTimer(): void
|
||||
{
|
||||
if (! $this->timer_started_at) {
|
||||
$this->update(['timer_started_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function resetTimer(): void
|
||||
{
|
||||
$this->update(['time_spent_seconds' => 0, 'timer_started_at' => null]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually overrides the tracked total (an operator correcting the
|
||||
* timer). If it's currently running, the running segment is re-based to
|
||||
* start now, so the edited value takes effect immediately instead of
|
||||
* being added to on top of the segment already in progress.
|
||||
*/
|
||||
public function setTimeSpent(int $seconds): void
|
||||
{
|
||||
$this->update([
|
||||
'time_spent_seconds' => max(0, $seconds),
|
||||
'timer_started_at' => $this->timer_started_at ? now() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom field values for this ticket's subcategory, in display order, skipping blanks.
|
||||
*
|
||||
* @return array<int, array{id: int, label: string, value: string}>
|
||||
*/
|
||||
public function customFieldEntries(): array
|
||||
{
|
||||
if (! $this->subcategory_id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$values = $this->custom_fields ?? [];
|
||||
|
||||
return $this->subcategory->customFields
|
||||
->filter(fn (CustomField $field) => array_key_exists($field->id, $values) && $values[$field->id] !== '' && $values[$field->id] !== null)
|
||||
->map(fn (CustomField $field) => [
|
||||
'id' => $field->id,
|
||||
'label' => $field->label,
|
||||
'value' => $field->type === 'checkbox'
|
||||
? ($values[$field->id] ? 'Tak' : 'Nie')
|
||||
: (string) $values[$field->id],
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
21
src/app/Models/TicketAttachment.php
Normal file
21
src/app/Models/TicketAttachment.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['ticket_id', 'message_id', 'original_name', 'path', 'size', 'mime'])]
|
||||
class TicketAttachment extends Model
|
||||
{
|
||||
public function ticket(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Ticket::class);
|
||||
}
|
||||
|
||||
public function message(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TicketMessage::class, 'message_id');
|
||||
}
|
||||
}
|
||||
25
src/app/Models/TicketHistory.php
Normal file
25
src/app/Models/TicketHistory.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['ticket_id', 'text', 'created_at'])]
|
||||
class TicketHistory extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function ticket(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Ticket::class);
|
||||
}
|
||||
}
|
||||
89
src/app/Models/TicketMessage.php
Normal file
89
src/app/Models/TicketMessage.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
|
||||
|
||||
#[Fillable(['ticket_id', 'author_name', 'internal', 'body', 'edited', 'api_client_id', 'created_at', 'updated_at'])]
|
||||
class TicketMessage extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'internal' => 'boolean',
|
||||
'edited' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* "role" and "author_id" are read-only virtual attributes derived from
|
||||
* authorLink (see below) — neither is a real column anymore, so this
|
||||
* keeps every existing `$message->role`/`$message->author_id` read
|
||||
* working unchanged. Write via attachAuthor() instead.
|
||||
*/
|
||||
public function getAttribute($key)
|
||||
{
|
||||
if ($key === 'role') {
|
||||
return $this->authorLink?->role?->key ?? 'system';
|
||||
}
|
||||
|
||||
if ($key === 'author_id') {
|
||||
return $this->authorLink?->user_id;
|
||||
}
|
||||
|
||||
return parent::getAttribute($key);
|
||||
}
|
||||
|
||||
public function ticket(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Ticket::class);
|
||||
}
|
||||
|
||||
public function apiClient(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ApiClient::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who wrote this message and in what role — replaces the old flat
|
||||
* author_id/role columns. A message with no row here is a
|
||||
* system-generated entry (no author, no role).
|
||||
*/
|
||||
public function authorLink(): HasOne
|
||||
{
|
||||
return $this->hasOne(TicketMessageAuthor::class);
|
||||
}
|
||||
|
||||
public function author(): HasOneThrough
|
||||
{
|
||||
return $this->hasOneThrough(User::class, TicketMessageAuthor::class, 'ticket_message_id', 'id', 'id', 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Links this message to the given author + role (or does nothing for a
|
||||
* system message, i.e. $roleKey === null) — the write-side counterpart
|
||||
* to the role/author_id reads above, since neither is a plain column
|
||||
* anymore.
|
||||
*/
|
||||
public function attachAuthor(?int $userId, ?string $roleKey): void
|
||||
{
|
||||
if ($roleKey === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->authorLink()->create([
|
||||
'user_id' => $userId,
|
||||
'role_id' => Role::query()->where('key', $roleKey)->value('id'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(TicketAttachment::class, 'message_id');
|
||||
}
|
||||
}
|
||||
33
src/app/Models/TicketMessageAuthor.php
Normal file
33
src/app/Models/TicketMessageAuthor.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Links a TicketMessage to its author + the role they held when they wrote
|
||||
* it — replaces the old flat author_id/role columns on ticket_messages. A
|
||||
* message with no row here is a system-generated entry (no author, no role).
|
||||
*/
|
||||
#[Fillable(['ticket_message_id', 'user_id', 'role_id'])]
|
||||
class TicketMessageAuthor extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
public function message(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TicketMessage::class, 'ticket_message_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function role(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Role::class);
|
||||
}
|
||||
}
|
||||
223
src/app/Models/User.php
Normal file
223
src/app/Models/User.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
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 LdapRecord\Laravel\Auth\AuthenticatesWithLdap;
|
||||
use LdapRecord\Laravel\Auth\LdapAuthenticatable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password', 'roles', 'custom_field_values'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable implements LdapAuthenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use AuthenticatesWithLdap, HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* Pending values for the "roles" / "custom_field_values" virtual
|
||||
* attributes (see getAttribute()/setAttribute() below) — neither is a
|
||||
* real column anymore, so a plain write can't land in $attributes; it's
|
||||
* stashed here until saved() flushes it into role_user / user_field_values.
|
||||
*/
|
||||
protected ?array $pendingRoleKeys = null;
|
||||
|
||||
protected ?array $pendingFieldValues = null;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saved(function (User $user) {
|
||||
if ($user->pendingRoleKeys !== null) {
|
||||
$ids = Role::query()->whereIn('key', $user->pendingRoleKeys)->pluck('id');
|
||||
$user->roleAssignments()->sync($ids);
|
||||
$user->unsetRelation('roleAssignments');
|
||||
$user->pendingRoleKeys = null;
|
||||
}
|
||||
|
||||
if ($user->pendingFieldValues !== null) {
|
||||
$user->syncFieldValues($user->pendingFieldValues);
|
||||
$user->unsetRelation('fieldValues');
|
||||
$user->pendingFieldValues = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an attribute's value. Overridden (rather than using an Eloquent
|
||||
* "Attribute" get/set mutator) because "roles" and "custom_field_values"
|
||||
* no longer back a real column at all — there's nothing for the
|
||||
* mutator's return value to be written into.
|
||||
*/
|
||||
public function getAttribute($key)
|
||||
{
|
||||
if ($key === 'roles') {
|
||||
return $this->pendingRoleKeys ?? $this->roleAssignments->pluck('key')->all();
|
||||
}
|
||||
|
||||
if ($key === 'custom_field_values') {
|
||||
return $this->pendingFieldValues ?? $this->fieldValues->pluck('value', 'user_field_id')->all();
|
||||
}
|
||||
|
||||
return parent::getAttribute($key);
|
||||
}
|
||||
|
||||
public function setAttribute($key, $value)
|
||||
{
|
||||
if ($key === 'roles') {
|
||||
$this->pendingRoleKeys = $value ?? [];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
if ($key === 'custom_field_values') {
|
||||
$this->pendingFieldValues = $value ?? [];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
return parent::setAttribute($key, $value);
|
||||
}
|
||||
|
||||
protected function syncFieldValues(array $values): void
|
||||
{
|
||||
$keep = [];
|
||||
|
||||
foreach ($values as $fieldId => $value) {
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$keep[] = $fieldId;
|
||||
|
||||
UserFieldValue::query()->updateOrCreate(
|
||||
['user_id' => $this->id, 'user_field_id' => $fieldId],
|
||||
['value' => is_bool($value) ? ($value ? '1' : '0') : (string) $value]
|
||||
);
|
||||
}
|
||||
|
||||
UserFieldValue::query()->where('user_id', $this->id)->whereNotIn('user_field_id', $keep)->delete();
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The roles this user actually holds, stored via the role_user pivot —
|
||||
* see the roles virtual attribute above for the plain-array reader/writer
|
||||
* that everything else in the app uses instead of this relation directly.
|
||||
*/
|
||||
public function roleAssignments(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Role::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters to users holding the given role — replaces the old
|
||||
* whereJsonContains('roles', $key) now that roles live in role_user.
|
||||
*/
|
||||
public function scopeWithRole(Builder $query, string $roleKey): Builder
|
||||
{
|
||||
return $query->whereHas('roleAssignments', fn (Builder $q) => $q->where('key', $roleKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* A user can hold several roles at once (e.g. operator + admin) — each
|
||||
* grants access to its matching area independently.
|
||||
*/
|
||||
public function hasRole(string $role): bool
|
||||
{
|
||||
return in_array($role, $this->roles ?? [], true);
|
||||
}
|
||||
|
||||
public function isClient(): bool
|
||||
{
|
||||
return $this->hasRole('client');
|
||||
}
|
||||
|
||||
public function isOperator(): bool
|
||||
{
|
||||
return $this->hasRole('operator');
|
||||
}
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->hasRole('admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to land a user with (possibly) several roles right after login —
|
||||
* always the client area, even for admins/operators, since every account
|
||||
* carries the "client" role by default (see AssignDefaultRole) and staff
|
||||
* switch into their other areas afterwards via the role switcher in the
|
||||
* header rather than landing there automatically.
|
||||
*/
|
||||
public function defaultArea(): string
|
||||
{
|
||||
return match (true) {
|
||||
$this->isClient() => '/client',
|
||||
$this->isAdmin() => '/admin',
|
||||
$this->isOperator() => '/operator',
|
||||
default => '/client',
|
||||
};
|
||||
}
|
||||
|
||||
public function teams(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Team::class, 'team_user');
|
||||
}
|
||||
|
||||
public function ticketsAsCustomer(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class, 'customer_id');
|
||||
}
|
||||
|
||||
public function ticketsAssigned(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class, 'assignee_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin-defined "user field" values, stored one row per field in
|
||||
* user_field_values — see the custom_field_values virtual attribute
|
||||
* above for the plain array-by-field-id reader/writer everything else
|
||||
* in the app uses instead of this relation directly.
|
||||
*/
|
||||
public function fieldValues(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserFieldValue::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin-defined "user field" values that actually have something in
|
||||
* them, label + display value, for showing next to a reporter/user
|
||||
* elsewhere in the app (mirrors Ticket::customFieldEntries()).
|
||||
*/
|
||||
public function customFieldEntries(): array
|
||||
{
|
||||
$values = $this->custom_field_values ?? [];
|
||||
|
||||
return UserField::query()->orderBy('sort_order')->get()
|
||||
->filter(fn (UserField $field) => array_key_exists($field->id, $values) && $values[$field->id] !== '' && $values[$field->id] !== null)
|
||||
->map(fn (UserField $field) => [
|
||||
'label' => $field->label,
|
||||
'value' => $field->type === 'checkbox'
|
||||
? ($values[$field->id] ? 'Tak' : 'Nie')
|
||||
: (string) $values[$field->id],
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
30
src/app/Models/UserField.php
Normal file
30
src/app/Models/UserField.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['label', 'description', 'type', 'options', 'ldap_attribute', 'sort_order'])]
|
||||
class UserField extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'options' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function typeLabel(): string
|
||||
{
|
||||
return match ($this->type) {
|
||||
'text' => 'Tekst krótki',
|
||||
'textarea' => 'Tekst długi',
|
||||
'select' => 'Lista wyboru',
|
||||
'checkbox' => 'Checkbox',
|
||||
'date' => 'Data',
|
||||
'number' => 'Liczba',
|
||||
default => $this->type,
|
||||
};
|
||||
}
|
||||
}
|
||||
21
src/app/Models/UserFieldValue.php
Normal file
21
src/app/Models/UserFieldValue.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['user_id', 'user_field_id', 'value'])]
|
||||
class UserFieldValue extends Model
|
||||
{
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function field(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(UserField::class, 'user_field_id');
|
||||
}
|
||||
}
|
||||
50
src/app/Notifications/TicketNotification.php
Normal file
50
src/app/Notifications/TicketNotification.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\Ticket;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class TicketNotification extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(protected Ticket $ticket, protected int $emailTemplateId) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$template = EmailTemplate::query()->find($this->emailTemplateId);
|
||||
|
||||
$firstName = trim(explode(' ', $this->ticket->name)[0] ?? $this->ticket->name);
|
||||
|
||||
$rendered = $template?->render([
|
||||
'numer' => $this->ticket->number,
|
||||
'imie' => $firstName,
|
||||
'temat' => $this->ticket->subject,
|
||||
'status' => $this->ticket->statusLabel(),
|
||||
'kategoria' => $this->ticket->categoryLabel(),
|
||||
'priorytet' => $this->ticket->priorityLabel(),
|
||||
'zespol' => $this->ticket->team?->name ?? 'Brak',
|
||||
'operator' => $this->ticket->assignee?->name ?? 'Nieprzypisane',
|
||||
'link' => route('client.ticket', $this->ticket),
|
||||
]) ?? [
|
||||
'subject' => 'Zgłoszenie #'.$this->ticket->number,
|
||||
'body' => $this->ticket->subject,
|
||||
];
|
||||
|
||||
return (new MailMessage)
|
||||
->subject($rendered['subject'])
|
||||
->view('emails.ticket-notification', [
|
||||
'html' => Settings::renderEmailLayout($rendered['body']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
169
src/app/Providers/AppServiceProvider.php
Normal file
169
src/app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\ApiClient;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use LdapRecord\Connection;
|
||||
use LdapRecord\Container;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->applyLdapSettingsOverride();
|
||||
$this->applyMailSettingsOverride();
|
||||
$this->applySessionSettingsOverride();
|
||||
$this->applyTimezoneSettingsOverride();
|
||||
$this->configureApiRateLimiting();
|
||||
|
||||
Relation::enforceMorphMap(['api_client' => ApiClient::class]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API keys get a generous per-key budget; unauthenticated requests (which
|
||||
* only ever hit the guard before rejecting with 401) get a much smaller
|
||||
* per-IP one so a single misconfigured client can't exhaust it for everyone.
|
||||
*/
|
||||
protected function configureApiRateLimiting(): void
|
||||
{
|
||||
RateLimiter::for('api', function (Request $request) {
|
||||
$clientId = $request->user()?->id;
|
||||
|
||||
return $clientId
|
||||
? Limit::perMinute(120)->by('api-client:'.$clientId)
|
||||
: Limit::perMinute(30)->by('ip:'.$request->ip());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Avoid touching the DB during artisan commands that run before the
|
||||
* `settings` table exists (e.g. `migrate` itself), or before it can be
|
||||
* queried at all — shared by every settings-driven config override below.
|
||||
*/
|
||||
protected function settingsTableUsable(): bool
|
||||
{
|
||||
if ($this->app->runningInConsole() && ! $this->app->runningUnitTests()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return Schema::hasTable('settings');
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Let the Admin -> Konfiguracja -> LDAP/AD screen override the .env-based
|
||||
* LDAP connection at runtime, so changes take effect without a redeploy.
|
||||
*/
|
||||
protected function applyLdapSettingsOverride(): void
|
||||
{
|
||||
if (! $this->settingsTableUsable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$host = Settings::get('ldap_host');
|
||||
|
||||
if (! $host) {
|
||||
return;
|
||||
}
|
||||
|
||||
$config = Config::get('ldap.connections.default', []);
|
||||
$config['hosts'] = [$host];
|
||||
$config['port'] = (int) Settings::get('ldap_port', $config['port'] ?? 389);
|
||||
$config['base_dn'] = Settings::get('ldap_base_dn', $config['base_dn'] ?? '');
|
||||
$config['username'] = Settings::get('ldap_bind_dn', $config['username'] ?? '');
|
||||
$config['password'] = Settings::get('ldap_bind_password') ?? $config['password'] ?? '';
|
||||
$config['use_tls'] = Settings::bool('ldap_use_ssl');
|
||||
|
||||
Config::set('ldap.connections.default', $config);
|
||||
Container::addConnection(new Connection($config), 'default');
|
||||
}
|
||||
|
||||
/**
|
||||
* Let the Admin -> Konfiguracja -> E-mail (SMTP) screen override the
|
||||
* .env-based mail transport/sender at runtime. Sender address/name and
|
||||
* footer apply regardless; the SMTP transport itself only when the admin
|
||||
* has explicitly enabled it (otherwise the .env `MAIL_MAILER` stands).
|
||||
*/
|
||||
protected function applyMailSettingsOverride(): void
|
||||
{
|
||||
if (! $this->settingsTableUsable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Settings::bool('mail_smtp_enabled') && Settings::get('mail_smtp_host')) {
|
||||
Config::set('mail.default', 'smtp');
|
||||
Config::set('mail.mailers.smtp.host', Settings::get('mail_smtp_host'));
|
||||
Config::set('mail.mailers.smtp.port', (int) Settings::get('mail_smtp_port', '587'));
|
||||
Config::set('mail.mailers.smtp.username', Settings::get('mail_smtp_username') ?: null);
|
||||
Config::set('mail.mailers.smtp.password', Settings::get('mail_smtp_password'));
|
||||
Config::set('mail.mailers.smtp.scheme', match (Settings::get('mail_smtp_encryption')) {
|
||||
'ssl' => 'smtps',
|
||||
'tls' => 'smtp',
|
||||
default => null,
|
||||
});
|
||||
}
|
||||
|
||||
if ($address = Settings::get('mail_from_address')) {
|
||||
Config::set('mail.from.address', $address);
|
||||
Config::set('mail.from.name', Settings::get('mail_from_name') ?: Settings::get('company_name'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Let the Admin -> Konfiguracja -> "Czas wygaśnięcia sesji" field override
|
||||
* the .env-based session lifetime. Only affects sessions created/renewed
|
||||
* after the change — already-active sessions keep their existing expiry.
|
||||
*/
|
||||
protected function applySessionSettingsOverride(): void
|
||||
{
|
||||
if (! $this->settingsTableUsable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$minutes = Settings::get('session_lifetime_minutes');
|
||||
|
||||
if ($minutes) {
|
||||
Config::set('session.lifetime', (int) $minutes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Let the Admin -> Konfiguracja -> "Strefa czasowa" field override the
|
||||
* .env-based app timezone at runtime, so every date shown or stored
|
||||
* (ticket timestamps, SLA deadlines, "x minutes ago" labels, ...)
|
||||
* reflects the admin's chosen zone instead of the container's UTC default.
|
||||
*/
|
||||
protected function applyTimezoneSettingsOverride(): void
|
||||
{
|
||||
if (! $this->settingsTableUsable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$timezone = Settings::timezone();
|
||||
|
||||
Config::set('app.timezone', $timezone);
|
||||
date_default_timezone_set($timezone);
|
||||
}
|
||||
}
|
||||
127
src/app/Services/LdapUserProvisioner.php
Normal file
127
src/app/Services/LdapUserProvisioner.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Ldap\LldapUser;
|
||||
use App\Models\User;
|
||||
use App\Models\UserField;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Bridges local user accounts to their LDAP identity outside of the login
|
||||
* flow: auto-creates an account the first time someone with a known LDAP
|
||||
* e-mail files a ticket as a guest, and fills the admin-defined "user field"
|
||||
* values (custom_field_values) from whichever LDAP attribute each is mapped to.
|
||||
*/
|
||||
class LdapUserProvisioner
|
||||
{
|
||||
/**
|
||||
* Find a local user by e-mail, or create one from a matching LDAP entry.
|
||||
* Returns null when there's no local user and no matching LDAP entry —
|
||||
* the caller should fall back to its previous "anonymous guest" behavior.
|
||||
*/
|
||||
/**
|
||||
* Read-only check used to gate manual account creation (Admin ->
|
||||
* Użytkownicy -> "Zaproś użytkownika") when the admin has restricted it
|
||||
* to only known LDAP identities — unlike findOrCreateByEmail(), this
|
||||
* never creates anything.
|
||||
*/
|
||||
public function existsInLdap(string $email): bool
|
||||
{
|
||||
return $this->findLdapEntryByEmail($email) !== null;
|
||||
}
|
||||
|
||||
public function findOrCreateByEmail(string $email): ?User
|
||||
{
|
||||
if ($user = User::query()->where('email', $email)->first()) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$ldapEntry = $this->findLdapEntryByEmail($email);
|
||||
|
||||
if (! $ldapEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = User::query()->create([
|
||||
'name' => $ldapEntry->getFirstAttribute('cn') ?? $email,
|
||||
'email' => $email,
|
||||
'roles' => ['client'],
|
||||
]);
|
||||
|
||||
$user->guid = $ldapEntry->getConvertedGuid();
|
||||
$user->save();
|
||||
|
||||
$this->applyFieldsFromLdap($user, $ldapEntry);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-pull user field values from LDAP for every existing local user.
|
||||
* Returns how many were actually matched against an LDAP entry.
|
||||
*/
|
||||
public function syncAll(): int
|
||||
{
|
||||
$matched = 0;
|
||||
|
||||
foreach (User::query()->get() as $user) {
|
||||
$ldapEntry = $this->findLdapEntryForUser($user);
|
||||
|
||||
if (! $ldapEntry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->applyFieldsFromLdap($user, $ldapEntry);
|
||||
$matched++;
|
||||
}
|
||||
|
||||
return $matched;
|
||||
}
|
||||
|
||||
protected function findLdapEntryForUser(User $user): ?LldapUser
|
||||
{
|
||||
if ($user->guid) {
|
||||
try {
|
||||
$byGuid = LldapUser::query()->where('entryuuid', '=', $user->guid)->first();
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('LDAP lookup by guid failed: '.$e->getMessage());
|
||||
$byGuid = null;
|
||||
}
|
||||
|
||||
if ($byGuid) {
|
||||
return $byGuid;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->findLdapEntryByEmail($user->email);
|
||||
}
|
||||
|
||||
protected function findLdapEntryByEmail(string $email): ?LldapUser
|
||||
{
|
||||
try {
|
||||
return LldapUser::query()->where('mail', '=', $email)->first();
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('LDAP lookup by email failed: '.$e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function applyFieldsFromLdap(User $user, LldapUser $ldapEntry): void
|
||||
{
|
||||
$values = $user->custom_field_values ?? [];
|
||||
|
||||
foreach (UserField::query()->whereNotNull('ldap_attribute')->where('ldap_attribute', '!=', '')->get() as $field) {
|
||||
$value = $ldapEntry->getFirstAttribute($field->ldap_attribute);
|
||||
|
||||
if ($value !== null) {
|
||||
$values[$field->id] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$user->custom_field_values = $values;
|
||||
$user->save();
|
||||
}
|
||||
}
|
||||
288
src/app/Services/TicketService.php
Normal file
288
src/app/Services/TicketService.php
Normal file
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ApiClient;
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Models\Priority;
|
||||
use App\Models\Status;
|
||||
use App\Models\Subcategory;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketMessage;
|
||||
use App\Models\User;
|
||||
use App\Notifications\TicketNotification;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class TicketService
|
||||
{
|
||||
/**
|
||||
* Create a ticket, either from a guest submission (no $customer) or on behalf of a logged-in client.
|
||||
*/
|
||||
public function create(array $data, ?User $customer, ?string $authorName = null): Ticket
|
||||
{
|
||||
if (! $customer && ! empty($data['email']) && Settings::bool('ldap_auto_provision_guests')) {
|
||||
$customer = app(LdapUserProvisioner::class)->findOrCreateByEmail($data['email']);
|
||||
}
|
||||
|
||||
$subcategory = ! empty($data['subcategory_id'])
|
||||
? Subcategory::query()->find($data['subcategory_id'])
|
||||
: null;
|
||||
|
||||
$ticket = Ticket::query()->create([
|
||||
'number' => Ticket::nextNumber(),
|
||||
'customer_id' => $customer?->id,
|
||||
'email' => $customer?->email ?? $data['email'],
|
||||
'name' => $customer?->name ?? ($data['name'] ?? $data['email']),
|
||||
'subcategory_id' => $subcategory?->id,
|
||||
'subject' => $data['subject'],
|
||||
'body' => $data['body'],
|
||||
'status_key' => Settings::get('default_status', 'new'),
|
||||
'priority_key' => $subcategory?->default_priority_key ?: 'medium',
|
||||
'team_id' => $this->autoAssignTeam($subcategory),
|
||||
'assignee_id' => $data['assignee_id'] ?? null,
|
||||
'custom_fields' => $data['custom_values'] ?? [],
|
||||
]);
|
||||
|
||||
$message = $ticket->messages()->create([
|
||||
'author_name' => $authorName ?? $ticket->name,
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
$message->attachAuthor($customer?->id, 'client');
|
||||
|
||||
$this->notify($ticket, 'ticket_created');
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
protected function autoAssignTeam(?Subcategory $subcategory): ?int
|
||||
{
|
||||
if (! $subcategory || ! Settings::bool('auto_assign_by_category')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Team::query()->whereHas('subcategories', fn ($q) => $q->where('subcategories.id', $subcategory->id))
|
||||
->value('id');
|
||||
}
|
||||
|
||||
public function setStatus(Ticket $ticket, string $statusKey): void
|
||||
{
|
||||
// Any status change (closing, reopening, moving between open sub-statuses)
|
||||
// resets the SLA-breach alert so a still-open ticket can report again later.
|
||||
$ticket->update(['status_key' => $statusKey, 'sla_notified_at' => null]);
|
||||
$ticket->addHistory('Status zmieniony na: '.Status::labelFor($statusKey));
|
||||
|
||||
$this->notify($ticket, 'status_changed');
|
||||
|
||||
if ($statusKey === 'closed') {
|
||||
$this->notify($ticket, 'ticket_closed');
|
||||
}
|
||||
}
|
||||
|
||||
public function setPriority(Ticket $ticket, string $priorityKey): void
|
||||
{
|
||||
$ticket->update(['priority_key' => $priorityKey]);
|
||||
$ticket->addHistory('Priorytet zmieniony na: '.Priority::labelFor($priorityKey));
|
||||
$this->notify($ticket, 'priority_changed');
|
||||
}
|
||||
|
||||
public function setAssignee(Ticket $ticket, ?User $assignee): void
|
||||
{
|
||||
// A newly assigned operator should get a fresh chance at an SLA-breach
|
||||
// alert instead of staying silent because the previous one already fired.
|
||||
$ticket->update(['assignee_id' => $assignee?->id, 'sla_notified_at' => null]);
|
||||
$ticket->addHistory('Przypisano do: '.($assignee?->name ?? 'Nieprzypisane'));
|
||||
$this->notify($ticket, 'assignee_changed');
|
||||
}
|
||||
|
||||
public function setTeam(Ticket $ticket, ?Team $team): void
|
||||
{
|
||||
$ticket->update(['team_id' => $team?->id]);
|
||||
$ticket->addHistory('Zespół zmieniony na: '.($team?->name ?? 'Brak'));
|
||||
$this->notify($ticket, 'team_changed');
|
||||
}
|
||||
|
||||
public function setReporter(Ticket $ticket, User $customer): void
|
||||
{
|
||||
$ticket->update(['customer_id' => $customer->id, 'email' => $customer->email, 'name' => $customer->name]);
|
||||
$ticket->addHistory('Zgłaszający zmieniony na: '.$customer->name);
|
||||
}
|
||||
|
||||
public function updateDetails(Ticket $ticket, array $data): void
|
||||
{
|
||||
$categoryChanged = ($data['subcategory_id'] ?? null) !== $ticket->subcategory_id;
|
||||
|
||||
$ticket->update([
|
||||
'subject' => $data['subject'],
|
||||
'body' => $data['body'],
|
||||
'subcategory_id' => $data['subcategory_id'] ?? null,
|
||||
'custom_fields' => $data['custom_values'] ?? [],
|
||||
]);
|
||||
$ticket->addHistory('Zaktualizowano dane zgłoszenia');
|
||||
|
||||
if ($categoryChanged) {
|
||||
$this->notify($ticket, 'category_changed');
|
||||
}
|
||||
}
|
||||
|
||||
public function operatorReply(Ticket $ticket, User $operator, string $body, ?string $statusAfter = null, array $attachments = []): void
|
||||
{
|
||||
$message = $ticket->messages()->create([
|
||||
'author_name' => $operator->name,
|
||||
'body' => $body,
|
||||
]);
|
||||
$message->attachAuthor($operator->id, 'operator');
|
||||
$ticket->touch();
|
||||
$this->attachFiles($ticket, $message, $attachments);
|
||||
$this->notify($ticket, 'operator_replied');
|
||||
|
||||
if ($statusAfter) {
|
||||
$this->setStatus($ticket, $statusAfter);
|
||||
}
|
||||
}
|
||||
|
||||
public function operatorNote(Ticket $ticket, User $operator, string $body, array $attachments = []): void
|
||||
{
|
||||
$message = $ticket->messages()->create([
|
||||
'author_name' => $operator->name,
|
||||
'internal' => true,
|
||||
'body' => $body,
|
||||
]);
|
||||
$message->attachAuthor($operator->id, 'operator');
|
||||
$this->attachFiles($ticket, $message, $attachments);
|
||||
}
|
||||
|
||||
public function clientReply(Ticket $ticket, User $client, string $body, array $attachments = []): void
|
||||
{
|
||||
$message = $ticket->messages()->create([
|
||||
'author_name' => $client->name,
|
||||
'body' => $body,
|
||||
]);
|
||||
$message->attachAuthor($client->id, 'client');
|
||||
$ticket->touch();
|
||||
$this->attachFiles($ticket, $message, $attachments);
|
||||
}
|
||||
|
||||
/**
|
||||
* A message posted by an API integration rather than a logged-in person —
|
||||
* no User to attach as author, so it lands as a "system" message (mirrors
|
||||
* merge()'s system notes). Public replies still fire the same outward
|
||||
* notification as an operator's reply; internal notes don't.
|
||||
*/
|
||||
public function apiMessage(Ticket $ticket, ApiClient $client, string $body, bool $internal, string $authorName, array $attachments = []): TicketMessage
|
||||
{
|
||||
$message = $ticket->messages()->create([
|
||||
'author_name' => $authorName,
|
||||
'internal' => $internal,
|
||||
'body' => $body,
|
||||
'api_client_id' => $client->id,
|
||||
]);
|
||||
$message->attachAuthor(null, null);
|
||||
$ticket->touch();
|
||||
$this->attachFiles($ticket, $message, $attachments);
|
||||
|
||||
if (! $internal) {
|
||||
$this->notify($ticket, 'operator_replied');
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores each uploaded file and links it to the given message (or, for a
|
||||
* ticket's opening message, to $message === null-safe callers passing the
|
||||
* ticket's first message) — shared by every attachment-picking flow,
|
||||
* including the initial ticket-creation forms.
|
||||
*
|
||||
* @param UploadedFile[] $attachments
|
||||
*/
|
||||
public function attachFiles(Ticket $ticket, ?TicketMessage $message, array $attachments): void
|
||||
{
|
||||
if (! $attachments || ! Settings::bool('allow_attachments')) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($attachments as $attachment) {
|
||||
$path = $attachment->store('attachments', 'public');
|
||||
|
||||
$ticket->attachments()->create([
|
||||
'message_id' => $message?->id,
|
||||
'original_name' => $attachment->getClientOriginalName(),
|
||||
'path' => $path,
|
||||
'size' => Storage::disk('public')->size($path),
|
||||
'mime' => $attachment->getMimeType(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge tickets: first selected id is primary, others get closed and their
|
||||
* messages copied onto the primary (mirrors the design's mergeSelected()).
|
||||
*/
|
||||
public function merge(array $ticketIds): void
|
||||
{
|
||||
if (count($ticketIds) < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tickets = Ticket::query()->whereIn('id', $ticketIds)->get()->keyBy('id');
|
||||
$primaryId = $ticketIds[0];
|
||||
$primary = $tickets->get($primaryId);
|
||||
$others = $tickets->except($primaryId);
|
||||
|
||||
if (! $primary || $others->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$primary->messages()->create([
|
||||
'author_name' => 'System',
|
||||
'body' => 'Scalono zgłoszenia: '.$others->map(fn (Ticket $o) => '#'.$o->number)->implode(', '),
|
||||
]);
|
||||
|
||||
foreach ($others as $other) {
|
||||
foreach ($other->messages()->get() as $message) {
|
||||
$copy = $primary->messages()->create([
|
||||
'author_name' => $message->author_name,
|
||||
'internal' => $message->internal,
|
||||
'body' => $message->body,
|
||||
]);
|
||||
$copy->attachAuthor($message->author_id, $message->authorLink?->role?->key);
|
||||
}
|
||||
|
||||
$other->update(['status_key' => 'closed']);
|
||||
$note = $other->messages()->create([
|
||||
'author_name' => 'System',
|
||||
'internal' => true,
|
||||
'body' => 'Scalone ze zgłoszeniem #'.$primary->number,
|
||||
]);
|
||||
$note->attachAuthor(null, 'operator');
|
||||
}
|
||||
|
||||
$primary->touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Public so the scheduled SLA-breach check (which isn't a ticket lifecycle
|
||||
* event raised from within this service) can trigger the same way.
|
||||
*/
|
||||
public function notify(Ticket $ticket, string $triggerKey): void
|
||||
{
|
||||
$setting = NotificationSetting::query()->where('trigger_key', $triggerKey)->first();
|
||||
|
||||
if (! $setting || ! $setting->enabled || ! $setting->email_template_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$email = $setting->recipient === 'operator' ? $ticket->assignee?->email : $ticket->email;
|
||||
|
||||
if (! $email) {
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::route('mail', $email)
|
||||
->notify(new TicketNotification($ticket, $setting->email_template_id));
|
||||
}
|
||||
}
|
||||
38
src/app/Support/Rel.php
Normal file
38
src/app/Support/Rel.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
class Rel
|
||||
{
|
||||
/**
|
||||
* Polish relative-time label, mirrors the design prototype's formatRel().
|
||||
*/
|
||||
public static function format(Carbon $time): string
|
||||
{
|
||||
$minutes = (int) round($time->diffInSeconds(now()) / 60);
|
||||
|
||||
if ($minutes < 1) {
|
||||
return 'przed chwilą';
|
||||
}
|
||||
|
||||
if ($minutes < 60) {
|
||||
return $minutes.' min temu';
|
||||
}
|
||||
|
||||
$hours = (int) round($minutes / 60);
|
||||
|
||||
if ($hours < 24) {
|
||||
return $hours.' godz. temu';
|
||||
}
|
||||
|
||||
$days = (int) round($hours / 24);
|
||||
|
||||
if ($days < 30) {
|
||||
return $days.' dni temu';
|
||||
}
|
||||
|
||||
return $time->format('d.m.Y');
|
||||
}
|
||||
}
|
||||
297
src/app/Support/Settings.php
Normal file
297
src/app/Support/Settings.php
Normal file
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Settings
|
||||
{
|
||||
protected static ?array $cache = null;
|
||||
|
||||
protected static array $defaults = [
|
||||
'company_name' => 'Servicedesk',
|
||||
'default_status' => 'new',
|
||||
'auto_assign_by_category' => '1',
|
||||
'allow_attachments' => '1',
|
||||
'attachment_max_size_kb' => '10240',
|
||||
'attachment_max_count' => '5',
|
||||
'attachment_max_total_size_kb' => '20480',
|
||||
'attachment_allowed_types' => 'jpg,jpeg,png,pdf,doc,docx,xls,xlsx,zip,txt',
|
||||
'session_lifetime_minutes' => '120',
|
||||
'timezone' => 'UTC',
|
||||
'ldap_enabled' => '1',
|
||||
'ldap_host' => '',
|
||||
'ldap_port' => '389',
|
||||
'ldap_base_dn' => '',
|
||||
'ldap_bind_dn' => '',
|
||||
'ldap_use_ssl' => '0',
|
||||
'ldap_user_filter' => '(uid={0})',
|
||||
'ldap_auto_provision_guests' => '1',
|
||||
'restrict_user_creation_to_ldap' => '0',
|
||||
'restrict_tickets_to_ldap' => '0',
|
||||
'mail_smtp_enabled' => '0',
|
||||
'mail_smtp_host' => '',
|
||||
'mail_smtp_port' => '587',
|
||||
'mail_smtp_username' => '',
|
||||
'mail_smtp_encryption' => 'tls',
|
||||
'mail_from_address' => '',
|
||||
'mail_from_name' => '',
|
||||
'email_footer' => '<p>Ta wiadomość została wygenerowana automatycznie przez system {firma} — prosimy na nią nie odpowiadać.</p>',
|
||||
'accent_color' => '#7c6fd6',
|
||||
'login_notice_type' => 'info',
|
||||
'login_notice_html' => '<p>Zaloguj się danymi z katalogu LDAP/AD Twojej organizacji.</p>',
|
||||
'email_layout_html' => '<div style="max-width:560px;margin:0 auto;padding:32px;background:#ffffff;border:1px solid #e5e5ea;border-radius:12px;font-family:Arial,Helvetica,sans-serif;color:#23252d">'
|
||||
.'<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#8a8a93;margin-bottom:18px">{firma}</div>'
|
||||
.'<div style="font-size:14px;line-height:1.6">{tresc}</div>'
|
||||
.'<div style="border-top:1px solid #e5e5ea;margin:24px 0 16px"></div>'
|
||||
.'<div style="font-size:12px;line-height:1.5;color:#8a8a93">{stopka}</div>'
|
||||
.'</div>',
|
||||
];
|
||||
|
||||
protected static array $encrypted = ['ldap_bind_password', 'mail_smtp_password'];
|
||||
|
||||
public static function get(string $key, ?string $default = null): ?string
|
||||
{
|
||||
static::load();
|
||||
|
||||
if (array_key_exists($key, static::$cache)) {
|
||||
$value = static::$cache[$key];
|
||||
|
||||
return in_array($key, static::$encrypted, true) && $value !== null
|
||||
? Crypt::decryptString($value)
|
||||
: $value;
|
||||
}
|
||||
|
||||
return $default ?? (static::$defaults[$key] ?? null);
|
||||
}
|
||||
|
||||
public static function bool(string $key): bool
|
||||
{
|
||||
return in_array(static::get($key), ['1', 'true', true, 1], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bundled default for a setting, ignoring whatever's currently
|
||||
* stored — used by admin "Resetuj" buttons (e.g. the e-mail layout/footer
|
||||
* editors) to restore a field without needing its default hardcoded twice.
|
||||
*/
|
||||
public static function default(string $key): ?string
|
||||
{
|
||||
return static::$defaults[$key] ?? null;
|
||||
}
|
||||
|
||||
public static function set(string $key, ?string $value): void
|
||||
{
|
||||
static::load();
|
||||
|
||||
$stored = in_array($key, static::$encrypted, true) && $value !== null
|
||||
? Crypt::encryptString($value)
|
||||
: $value;
|
||||
|
||||
Setting::query()->updateOrCreate(['key' => $key], ['value' => $stored]);
|
||||
static::$cache[$key] = $stored;
|
||||
}
|
||||
|
||||
public static function all(): array
|
||||
{
|
||||
static::load();
|
||||
$keys = array_unique(array_merge(array_keys(static::$defaults), array_keys(static::$cache)));
|
||||
|
||||
return collect($keys)->mapWithKeys(fn ($key) => [$key => static::get($key)])->all();
|
||||
}
|
||||
|
||||
protected static function load(): void
|
||||
{
|
||||
if (static::$cache !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
static::$cache = Setting::query()->pluck('value', 'key')->all();
|
||||
}
|
||||
|
||||
public static function flush(): void
|
||||
{
|
||||
static::$cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The site-wide accent color (drives buttons, links, focus rings, etc.).
|
||||
* Validated against a strict hex format since it's echoed straight into
|
||||
* a <style> tag in the base layout — anything malformed falls back to
|
||||
* the default rather than risking breaking every page's CSS.
|
||||
*/
|
||||
public static function accentColor(): string
|
||||
{
|
||||
$value = static::get('accent_color');
|
||||
|
||||
return is_string($value) && preg_match('/^#[0-9a-fA-F]{6}$/', $value)
|
||||
? $value
|
||||
: static::$defaults['accent_color'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The color/severity options for the login-page notice — key is what's
|
||||
* stored (and drives the box's color via .login-notice-{key} in CSS),
|
||||
* value is the admin-facing Polish label.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function loginNoticeTypes(): array
|
||||
{
|
||||
return [
|
||||
'info' => 'Informacja',
|
||||
'warning' => 'Ostrzeżenie',
|
||||
'success' => 'Sukces',
|
||||
'danger' => 'Ważne',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored login-notice severity, guarded against a stale/tampered
|
||||
* value that no longer matches a known type (falls back to "info").
|
||||
*/
|
||||
public static function loginNoticeType(): string
|
||||
{
|
||||
$value = static::get('login_notice_type');
|
||||
|
||||
return array_key_exists($value, static::loginNoticeTypes()) ? $value : 'info';
|
||||
}
|
||||
|
||||
/**
|
||||
* The site logo, shown on the login page (and previewed in the admin
|
||||
* branding tab) — the admin-uploaded image, or a bundled default mark.
|
||||
*/
|
||||
public static function logoUrl(): string
|
||||
{
|
||||
$path = static::get('company_logo_path');
|
||||
|
||||
return $path ? Storage::disk('public')->url($path) : asset('branding/default-mark.svg');
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser-tab favicon — the admin-uploaded SVG, or the same bundled
|
||||
* default mark used for the logo.
|
||||
*/
|
||||
public static function faviconUrl(): string
|
||||
{
|
||||
$path = static::get('favicon_path');
|
||||
|
||||
return $path ? Storage::disk('public')->url($path) : asset('branding/default-mark.svg');
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin-configured timezone (IANA identifier, e.g. "Europe/Warsaw"),
|
||||
* applied at runtime by AppServiceProvider so every date/time displayed
|
||||
* or stored throughout the app reflects it. Falls back to a valid
|
||||
* timezone if the stored value is somehow no longer recognized by PHP.
|
||||
*/
|
||||
public static function timezone(): string
|
||||
{
|
||||
$value = static::get('timezone');
|
||||
|
||||
return is_string($value) && in_array($value, \DateTimeZone::listIdentifiers(), true)
|
||||
? $value
|
||||
: static::$defaults['timezone'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a single e-mail template's rendered HTML body in the fixed
|
||||
* "box" layout — company name, ticket content and footer — so every
|
||||
* outgoing notification looks consistent regardless of which trigger
|
||||
* sent it. The box itself isn't admin-editable (by design); only its
|
||||
* footer is (see the footer editor in Admin -> Szablony e-mail).
|
||||
*/
|
||||
public static function renderEmailLayout(string $contentHtml): string
|
||||
{
|
||||
$layout = static::default('email_layout_html');
|
||||
$company = static::get('company_name', '');
|
||||
|
||||
// Resolve {firma} inside the footer itself first — strtr() below is a
|
||||
// single simultaneous pass, so a {firma} that only arrives *inside*
|
||||
// the substituted footer text would otherwise never get replaced.
|
||||
$footer = strtr(static::get('email_footer', ''), ['{firma}' => $company]);
|
||||
|
||||
return strtr($layout, [
|
||||
'{tresc}' => $contentHtml,
|
||||
'{stopka}' => $footer,
|
||||
'{firma}' => $company,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the admin-configurable LDAP user filter (e.g. "(uid={0})") to
|
||||
* find which LDAP attribute logins are matched against.
|
||||
*/
|
||||
public static function ldapUsernameAttribute(): string
|
||||
{
|
||||
$filter = static::get('ldap_user_filter', '(uid={0})');
|
||||
|
||||
if (preg_match('/\(([a-zA-Z0-9-]+)=\{0\}\)/', (string) $filter, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return 'uid';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks an uploaded attachment against the admin-configured max size and
|
||||
* allowed extensions. Returns a user-facing error message, or null if the
|
||||
* file is fine — shared by every ticket/message/note attachment picker so
|
||||
* the limits only need to be enforced in one place.
|
||||
*/
|
||||
public static function validateAttachment(UploadedFile $file): ?string
|
||||
{
|
||||
$maxKb = (int) static::get('attachment_max_size_kb');
|
||||
|
||||
if ($maxKb > 0 && $file->getSize() > $maxKb * 1024) {
|
||||
return 'Plik jest za duży (maks. '.round($maxKb / 1024, 1).' MB).';
|
||||
}
|
||||
|
||||
$allowed = array_filter(array_map('trim', explode(',', (string) static::get('attachment_allowed_types'))));
|
||||
$extension = strtolower($file->getClientOriginalExtension());
|
||||
|
||||
if ($allowed && ! in_array($extension, $allowed, true)) {
|
||||
return 'Niedozwolony typ pliku. Dozwolone: '.implode(', ', $allowed).'.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as validateAttachment() but for a whole batch picked at once —
|
||||
* checks the admin-configured max file count and combined size first
|
||||
* (since those only make sense across the set), then each file
|
||||
* individually. Returns the first error found, or null if the batch is fine.
|
||||
*
|
||||
* @param UploadedFile[] $files
|
||||
*/
|
||||
public static function validateAttachments(array $files): ?string
|
||||
{
|
||||
$maxCount = (int) static::get('attachment_max_count');
|
||||
|
||||
if ($maxCount > 0 && count($files) > $maxCount) {
|
||||
return "Można dodać maksymalnie {$maxCount} plików.";
|
||||
}
|
||||
|
||||
$maxTotalKb = (int) static::get('attachment_max_total_size_kb');
|
||||
|
||||
if ($maxTotalKb > 0) {
|
||||
$totalBytes = array_sum(array_map(fn (UploadedFile $file) => $file->getSize(), $files));
|
||||
|
||||
if ($totalBytes > $maxTotalKb * 1024) {
|
||||
return 'Łączny rozmiar załączników jest za duży (maks. '.round($maxTotalKb / 1024, 1).' MB).';
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($error = static::validateAttachment($file)) {
|
||||
return $error;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
18
src/artisan
Normal file
18
src/artisan
Normal 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);
|
||||
46
src/bootstrap/app.php
Normal file
46
src/bootstrap/app.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\EnsureRole;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
|
||||
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
// The app only ever receives traffic from the Traefik reverse proxy on the
|
||||
// internal docker network (TLS is terminated there), so trust its
|
||||
// X-Forwarded-* headers — otherwise Laravel thinks every request is plain
|
||||
// HTTP and generates http:// asset/URL links, which browsers block as
|
||||
// mixed content on the https:// site.
|
||||
$middleware->trustProxies(at: '*', headers: Request::HEADER_X_FORWARDED_FOR |
|
||||
Request::HEADER_X_FORWARDED_HOST |
|
||||
Request::HEADER_X_FORWARDED_PORT |
|
||||
Request::HEADER_X_FORWARDED_PROTO |
|
||||
Request::HEADER_X_FORWARDED_AWS_ELB);
|
||||
|
||||
$middleware->alias([
|
||||
'role' => EnsureRole::class,
|
||||
'abilities' => CheckAbilities::class,
|
||||
'ability' => CheckForAnyAbility::class,
|
||||
]);
|
||||
|
||||
// navigator.sendBeacon (used to stop the ticket timer on tab close,
|
||||
// see routes/web.php) can't attach a CSRF header/field.
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
'operator/tickets/*/stop-timer',
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
fn (Request $request) => $request->is('api/*'),
|
||||
);
|
||||
})->create();
|
||||
2
src/bootstrap/cache/.gitignore
vendored
Normal file
2
src/bootstrap/cache/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
7
src/bootstrap/providers.php
Normal file
7
src/bootstrap/providers.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
];
|
||||
97
src/composer.json
Normal file
97
src/composer.json
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"framework"
|
||||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"darkaonline/l5-swagger": "*",
|
||||
"directorytree/ldaprecord-laravel": "*",
|
||||
"laravel/framework": "^13.8",
|
||||
"laravel/sanctum": "*",
|
||||
"laravel/tinker": "^3.0",
|
||||
"livewire/livewire": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/boost": "^2.2",
|
||||
"laravel/pail": "^1.2.5",
|
||||
"laravel/pao": "^1.0.6",
|
||||
"laravel/pint": "^1.27",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"pestphp/pest": "^4.7",
|
||||
"pestphp/pest-plugin-laravel": "^4.1"
|
||||
},
|
||||
"autoload": {
|
||||
"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",
|
||||
"@php artisan scribe:generate",
|
||||
"npm install --ignore-scripts",
|
||||
"npm run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi @no_additional_args",
|
||||
"@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
|
||||
}
|
||||
10447
src/composer.lock
generated
Normal file
10447
src/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
138
src/config/app.php
Normal file
138
src/config/app.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?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'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Author Contact
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Shown as the support contact address on the Admin > About tab. Not a
|
||||
| framework setting — set AUTHOR_CONTACT in .env.
|
||||
|
|
||||
*/
|
||||
|
||||
'author_contact' => env('AUTHOR_CONTACT'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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'),
|
||||
],
|
||||
|
||||
];
|
||||
142
src/config/auth.php
Normal file
142
src/config/auth.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
use App\Ldap\Handlers\AssignDefaultRole;
|
||||
use App\Ldap\Handlers\SyncUserFieldsFromLdap;
|
||||
use App\Ldap\LldapUser;
|
||||
use App\Models\User;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Defaults
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default authentication "guard" and password
|
||||
| reset "broker" for your application. You may change these values
|
||||
| as required, but they're a perfect start for most applications.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'guard' => env('AUTH_GUARD', 'web'),
|
||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Next, you may define every authentication guard for your application.
|
||||
| Of course, a great default configuration has been defined for you
|
||||
| which utilizes session storage plus the Eloquent user provider.
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| Supported: "session"
|
||||
|
|
||||
*/
|
||||
|
||||
'guards' => [
|
||||
'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' => 'ldap',
|
||||
'model' => LldapUser::class,
|
||||
|
||||
// Synchronize the bound LDAP identity into our local `users` table.
|
||||
'database' => [
|
||||
'model' => User::class,
|
||||
|
||||
// Match an admin-invited placeholder row (created with no `guid` yet)
|
||||
// by e-mail, instead of creating a duplicate local account.
|
||||
'sync_existing' => [
|
||||
'email' => 'mail',
|
||||
],
|
||||
|
||||
'sync_attributes' => [
|
||||
'name' => 'cn',
|
||||
'email' => 'mail',
|
||||
'roles' => AssignDefaultRole::class,
|
||||
'custom_field_values' => SyncUserFieldsFromLdap::class,
|
||||
],
|
||||
|
||||
// Never overwrite the local `password` column — LDAP is authoritative.
|
||||
'sync_passwords' => false,
|
||||
],
|
||||
],
|
||||
|
||||
// Plain Eloquent provider kept around for Pest/local tooling that doesn't hit LDAP.
|
||||
'eloquent' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => env('AUTH_MODEL', User::class),
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resetting Passwords
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options specify the behavior of Laravel's password
|
||||
| reset functionality, including the table utilized for token storage
|
||||
| and the user provider that is invoked to actually retrieve users.
|
||||
|
|
||||
| The expiry time is the number of minutes that each reset token will be
|
||||
| considered valid. This security feature keeps tokens short-lived so
|
||||
| they have less time to be guessed. You may change this as needed.
|
||||
|
|
||||
| The throttle setting is the number of seconds a user must wait before
|
||||
| generating more password reset tokens. This prevents the user from
|
||||
| quickly generating a very large amount of password reset tokens.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => [
|
||||
'users' => [
|
||||
'provider' => 'users',
|
||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Confirmation Timeout
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the 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),
|
||||
|
||||
];
|
||||
136
src/config/cache.php
Normal file
136
src/config/cache.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?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", "storage", "octane",
|
||||
| "session", "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'),
|
||||
],
|
||||
|
||||
'storage' => [
|
||||
'driver' => 'storage',
|
||||
'disk' => env('CACHE_STORAGE_DISK'),
|
||||
'path' => env('CACHE_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-'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Serializable Classes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the classes that can be unserialized from cache
|
||||
| storage. By default, no PHP classes will be unserialized from your
|
||||
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
||||
|
|
||||
*/
|
||||
|
||||
'serializable_classes' => false,
|
||||
|
||||
];
|
||||
184
src/config/database.php
Normal file
184
src/config/database.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Pdo\Mysql;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Database Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which of the database connections below you wish
|
||||
| to use as your default connection for database operations. This is
|
||||
| the connection which will be utilized unless another connection
|
||||
| is explicitly specified when you execute a query / statement.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below are all of the database connections defined for your application.
|
||||
| An example configuration is provided for each database system which
|
||||
| is supported by Laravel. You're free to add / remove connections.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL'),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
'busy_timeout' => null,
|
||||
'journal_mode' => null,
|
||||
'synchronous' => null,
|
||||
'transaction_mode' => 'DEFERRED',
|
||||
],
|
||||
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
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([
|
||||
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', 'phpredis'),
|
||||
|
||||
'options' => [
|
||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||
'persistent' => env('REDIS_PERSISTENT', false),
|
||||
],
|
||||
|
||||
'default' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
'cache' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_CACHE_DB', '1'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
80
src/config/filesystems.php
Normal file
80
src/config/filesystems.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?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', 'local'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Filesystem Disks
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below you may configure as many filesystem disks as necessary, and you
|
||||
| may even configure multiple disks for the same driver. Examples for
|
||||
| most supported storage drivers are configured here for reference.
|
||||
|
|
||||
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||
|
|
||||
*/
|
||||
|
||||
'disks' => [
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private'),
|
||||
'serve' => true,
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION'),
|
||||
'bucket' => env('AWS_BUCKET'),
|
||||
'url' => env('AWS_URL'),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Symbolic Links
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the symbolic links that will be created when the
|
||||
| `storage:link` Artisan command is executed. The array keys should be
|
||||
| the locations of the links and the values should be their targets.
|
||||
|
|
||||
*/
|
||||
|
||||
'links' => [
|
||||
public_path('storage') => storage_path('app/public'),
|
||||
],
|
||||
|
||||
];
|
||||
284
src/config/l5-swagger.php
Normal file
284
src/config/l5-swagger.php
Normal file
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'default' => 'default',
|
||||
'documentations' => [
|
||||
'default' => [
|
||||
'api' => [
|
||||
'title' => config('app.name').' API Documentation',
|
||||
],
|
||||
|
||||
'routes' => [
|
||||
/*
|
||||
* Route for accessing api documentation interface
|
||||
*/
|
||||
'api' => 'admin/api-docs',
|
||||
],
|
||||
'paths' => [
|
||||
/*
|
||||
* Edit to include full URL in ui for assets
|
||||
*/
|
||||
'use_absolute_path' => env('L5_SWAGGER_USE_ABSOLUTE_PATH', true),
|
||||
|
||||
/*
|
||||
* Edit to set path where swagger ui assets should be stored
|
||||
*/
|
||||
'swagger_ui_assets_path' => env('L5_SWAGGER_UI_ASSETS_PATH', 'vendor/swagger-api/swagger-ui/dist/'),
|
||||
|
||||
/*
|
||||
* File name of the generated json documentation file
|
||||
*/
|
||||
'docs_json' => 'api-docs.json',
|
||||
|
||||
/*
|
||||
* File name of the generated YAML documentation file
|
||||
*/
|
||||
'docs_yaml' => 'api-docs.yaml',
|
||||
|
||||
/*
|
||||
* Set this to `json` or `yaml` to determine which documentation file to use in UI
|
||||
*/
|
||||
'format_to_use_for_docs' => env('L5_FORMAT_TO_USE_FOR_DOCS', 'json'),
|
||||
|
||||
/*
|
||||
* Absolute paths to directory containing the swagger annotations are stored.
|
||||
*/
|
||||
'annotations' => [
|
||||
base_path('app'),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'defaults' => [
|
||||
'routes' => [
|
||||
/*
|
||||
* Route for accessing parsed swagger annotations.
|
||||
*/
|
||||
'docs' => 'admin/api-docs.json',
|
||||
|
||||
/*
|
||||
* Route for Oauth2 authentication callback.
|
||||
*/
|
||||
'oauth2_callback' => 'api/oauth2-callback',
|
||||
|
||||
/*
|
||||
* Middleware allows to prevent unexpected access to API documentation
|
||||
* Reuses the same session-auth + admin-role gate as the rest of /admin.
|
||||
*/
|
||||
'middleware' => [
|
||||
'api' => ['web', 'auth', 'role:admin'],
|
||||
'asset' => [],
|
||||
'docs' => ['web', 'auth', 'role:admin'],
|
||||
'oauth2_callback' => [],
|
||||
],
|
||||
|
||||
/*
|
||||
* Route Group options
|
||||
*/
|
||||
'group_options' => [],
|
||||
],
|
||||
|
||||
'paths' => [
|
||||
/*
|
||||
* Absolute path to location where parsed annotations will be stored
|
||||
*/
|
||||
'docs' => storage_path('api-docs'),
|
||||
|
||||
/*
|
||||
* Absolute path to directory where to export views
|
||||
*/
|
||||
'views' => base_path('resources/views/vendor/l5-swagger'),
|
||||
|
||||
/*
|
||||
* Edit to set the api's base path
|
||||
*/
|
||||
'base' => env('L5_SWAGGER_BASE_PATH', config('app.url').'/api/v1'),
|
||||
|
||||
/*
|
||||
* Absolute path to directories that should be excluded from scanning
|
||||
* @deprecated Please use `scanOptions.exclude`
|
||||
* `scanOptions.exclude` overwrites this
|
||||
*/
|
||||
'excludes' => [],
|
||||
],
|
||||
|
||||
'scanOptions' => [
|
||||
/**
|
||||
* Optional CustomGeneratorInterface implementation that creates an OpenApi\Generator instance.
|
||||
* Use this to provide a custom pre-configured generator.
|
||||
* Accepts an instance or a class name (FQCN) implementing the interface.
|
||||
*
|
||||
* @see \L5Swagger\CustomGeneratorInterface
|
||||
*/
|
||||
'generator_factory' => null,
|
||||
|
||||
/**
|
||||
* Configuration for default processors. Allows to pass processors configuration to swagger-php.
|
||||
*
|
||||
* @link https://zircote.github.io/swagger-php/reference/processors.html
|
||||
*/
|
||||
'default_processors_configuration' => [
|
||||
/** Example */
|
||||
/**
|
||||
* 'operationId.hash' => true,
|
||||
* 'pathFilter' => [
|
||||
* 'tags' => [
|
||||
* '/pets/',
|
||||
* '/store/',
|
||||
* ],
|
||||
* ],.
|
||||
*/
|
||||
],
|
||||
|
||||
/**
|
||||
* analyser: defaults to \OpenApi\StaticAnalyser .
|
||||
*
|
||||
* @see \OpenApi\scan
|
||||
*/
|
||||
'analyser' => null,
|
||||
|
||||
/**
|
||||
* analysis: defaults to a new \OpenApi\Analysis .
|
||||
*
|
||||
* @see \OpenApi\scan
|
||||
*/
|
||||
'analysis' => null,
|
||||
|
||||
/**
|
||||
* Custom processors.
|
||||
*
|
||||
* Each entry can be:
|
||||
* - A class name or instance (inserted after BuildPaths by default)
|
||||
* - An array with 'class' and 'after' keys for precise positioning:
|
||||
* ['class' => MyProcessor::class, 'after' => SomeProcessor::class]
|
||||
*
|
||||
* @link https://github.com/zircote/swagger-php/tree/master/Examples/processors/schema-query-parameter
|
||||
* @see \OpenApi\scan
|
||||
*/
|
||||
'processors' => [
|
||||
// \App\SwaggerProcessors\SchemaQueryParameter::class,
|
||||
// ['class' => \App\SwaggerProcessors\Custom::class, 'after' => \OpenApi\Processors\AugmentSchemas::class],
|
||||
],
|
||||
|
||||
/**
|
||||
* pattern: string $pattern File pattern(s) to scan (default: *.php) .
|
||||
*
|
||||
* @see \OpenApi\scan
|
||||
*/
|
||||
'pattern' => null,
|
||||
|
||||
/*
|
||||
* Absolute path to directories that should be excluded from scanning
|
||||
* @note This option overwrites `paths.excludes`
|
||||
* @see \OpenApi\scan
|
||||
*/
|
||||
'exclude' => [],
|
||||
|
||||
/*
|
||||
* Allows to generate specs either for OpenAPI 3.0.0 or OpenAPI 3.1.0.
|
||||
* By default the spec will be in version 3.0.0
|
||||
*/
|
||||
'open_api_spec_version' => env('L5_SWAGGER_OPEN_API_SPEC_VERSION', \L5Swagger\Generator::OPEN_API_DEFAULT_SPEC_VERSION),
|
||||
],
|
||||
|
||||
/*
|
||||
* API security definitions. Will be generated into documentation file.
|
||||
*/
|
||||
'securityDefinitions' => [
|
||||
'securitySchemes' => [
|
||||
'sanctum' => [
|
||||
'type' => 'http',
|
||||
'scheme' => 'bearer',
|
||||
'description' => 'Klucz API wygenerujesz w panelu administratora, w zakładce Ustawienia → Klucze API.',
|
||||
],
|
||||
],
|
||||
'security' => [
|
||||
[
|
||||
'sanctum' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
* Set this to `true` in development mode so that docs would be regenerated on each request
|
||||
* Set this to `false` to disable swagger generation on production
|
||||
*/
|
||||
'generate_always' => env('L5_SWAGGER_GENERATE_ALWAYS', false),
|
||||
|
||||
/*
|
||||
* Set this to `true` to generate a copy of documentation in yaml format
|
||||
*/
|
||||
'generate_yaml_copy' => env('L5_SWAGGER_GENERATE_YAML_COPY', false),
|
||||
|
||||
/*
|
||||
* Edit to trust the proxy's ip address - needed for AWS Load Balancer
|
||||
* string[]
|
||||
*/
|
||||
'proxy' => false,
|
||||
|
||||
/*
|
||||
* Configs plugin allows to fetch external configs instead of passing them to SwaggerUIBundle.
|
||||
* See more at: https://github.com/swagger-api/swagger-ui#configs-plugin
|
||||
*/
|
||||
'additional_config_url' => null,
|
||||
|
||||
/*
|
||||
* Apply a sort to the operation list of each API. It can be 'alpha' (sort by paths alphanumerically),
|
||||
* 'method' (sort by HTTP method).
|
||||
* Default is the order returned by the server unchanged.
|
||||
*/
|
||||
'operations_sort' => env('L5_SWAGGER_OPERATIONS_SORT', null),
|
||||
|
||||
/*
|
||||
* Pass the validatorUrl parameter to SwaggerUi init on the JS side.
|
||||
* A null value here disables validation.
|
||||
*/
|
||||
'validator_url' => null,
|
||||
|
||||
/*
|
||||
* Swagger UI configuration parameters
|
||||
*/
|
||||
'ui' => [
|
||||
'display' => [
|
||||
'dark_mode' => env('L5_SWAGGER_UI_DARK_MODE', false),
|
||||
/*
|
||||
* Controls the default expansion setting for the operations and tags. It can be :
|
||||
* 'list' (expands only the tags),
|
||||
* 'full' (expands the tags and operations),
|
||||
* 'none' (expands nothing).
|
||||
*/
|
||||
'doc_expansion' => env('L5_SWAGGER_UI_DOC_EXPANSION', 'none'),
|
||||
|
||||
/**
|
||||
* If set, enables filtering. The top bar will show an edit box that
|
||||
* you can use to filter the tagged operations that are shown. Can be
|
||||
* Boolean to enable or disable, or a string, in which case filtering
|
||||
* will be enabled using that string as the filter expression. Filtering
|
||||
* is case-sensitive matching the filter expression anywhere inside
|
||||
* the tag.
|
||||
*/
|
||||
'filter' => env('L5_SWAGGER_UI_FILTERS', true), // true | false
|
||||
],
|
||||
|
||||
'authorization' => [
|
||||
/*
|
||||
* If set to true, it persists authorization data, and it would not be lost on browser close/refresh
|
||||
*/
|
||||
'persist_authorization' => env('L5_SWAGGER_UI_PERSIST_AUTHORIZATION', false),
|
||||
|
||||
'oauth2' => [
|
||||
/*
|
||||
* If set to true, adds PKCE to AuthorizationCodeGrant flow
|
||||
*/
|
||||
'use_pkce_with_authorization_code_grant' => false,
|
||||
],
|
||||
],
|
||||
],
|
||||
/*
|
||||
* Constants which can be used in annotations
|
||||
*/
|
||||
'constants' => [
|
||||
'L5_SWAGGER_CONST_HOST' => env('L5_SWAGGER_CONST_HOST', 'http://my-default-host.com'),
|
||||
],
|
||||
],
|
||||
];
|
||||
81
src/config/ldap.php
Normal file
81
src/config/ldap.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default LDAP Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which of the LDAP connections below you wish
|
||||
| to use as your default connection for all LDAP operations. Of
|
||||
| course you may add as many connections you'd like below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('LDAP_CONNECTION', 'default'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| LDAP Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below you may configure each LDAP connection your application requires
|
||||
| access to. Be sure to include a valid base DN - otherwise you may
|
||||
| not receive any results when performing LDAP search operations.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'default' => [
|
||||
'hosts' => [env('LDAP_HOST', '127.0.0.1')],
|
||||
'username' => env('LDAP_USERNAME', 'cn=user,dc=local,dc=com'),
|
||||
'password' => env('LDAP_PASSWORD', 'secret'),
|
||||
'port' => env('LDAP_PORT', 389),
|
||||
'base_dn' => env('LDAP_BASE_DN', 'dc=local,dc=com'),
|
||||
'timeout' => env('LDAP_TIMEOUT', 5),
|
||||
'use_tls' => env('LDAP_USE_SSL', false),
|
||||
'use_starttls' => env('LDAP_USE_TLS', false),
|
||||
'use_sasl' => env('LDAP_SASL', false),
|
||||
'sasl_options' => [
|
||||
// 'mech' => 'GSSAPI',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| LDAP Logging
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When LDAP logging is enabled, all LDAP search and authentication
|
||||
| operations are logged using the default application logging
|
||||
| driver. This can assist in debugging issues and more.
|
||||
|
|
||||
*/
|
||||
|
||||
'logging' => [
|
||||
'enabled' => env('LDAP_LOGGING', true),
|
||||
'channel' => env('LOG_CHANNEL', 'stack'),
|
||||
'level' => env('LOG_LEVEL', 'info'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| LDAP Cache
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| LDAP caching enables the ability of caching search results using the
|
||||
| query builder. This is great for running expensive operations that
|
||||
| may take many seconds to complete, such as a pagination request.
|
||||
|
|
||||
*/
|
||||
|
||||
'cache' => [
|
||||
'enabled' => env('LDAP_CACHE', false),
|
||||
'driver' => env('CACHE_DRIVER', 'file'),
|
||||
],
|
||||
|
||||
];
|
||||
282
src/config/livewire.php
Normal file
282
src/config/livewire.php
Normal file
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Component Locations
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This value sets the root directories that'll be used to resolve view-based
|
||||
| components like single and multi-file components. The make command will
|
||||
| use the first directory in this array to add new component files to.
|
||||
|
|
||||
*/
|
||||
|
||||
'component_locations' => [
|
||||
resource_path('views/components'),
|
||||
resource_path('views/livewire'),
|
||||
],
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Component Namespaces
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This value sets default namespaces that will be used to resolve view-based
|
||||
| components like single-file and multi-file components. These folders'll
|
||||
| also be referenced when creating new components via the make command.
|
||||
|
|
||||
*/
|
||||
|
||||
'component_namespaces' => [
|
||||
'layouts' => resource_path('views/layouts'),
|
||||
'pages' => resource_path('views/pages'),
|
||||
],
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Page Layout
|
||||
|---------------------------------------------------------------------------
|
||||
| The view that will be used as the layout when rendering a single component as
|
||||
| an entire page via `Route::livewire('/post/create', 'pages::create-post')`.
|
||||
| In this case, the content of pages::create-post will render into $slot.
|
||||
|
|
||||
*/
|
||||
|
||||
'component_layout' => 'layouts::app',
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Lazy Loading Placeholder
|
||||
|---------------------------------------------------------------------------
|
||||
| Livewire allows you to lazy load components that would otherwise slow down
|
||||
| the initial page load. Every component can have a custom placeholder or
|
||||
| you can define the default placeholder view for all components below.
|
||||
|
|
||||
*/
|
||||
|
||||
'component_placeholder' => null, // Example: 'placeholders::skeleton'
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Make Command
|
||||
|---------------------------------------------------------------------------
|
||||
| This value determines the default configuration for the artisan make command
|
||||
| You can configure the component type (sfc, mfc, class) and whether to use
|
||||
| the high-voltage (⚡) emoji as a prefix in the sfc|mfc component names.
|
||||
|
|
||||
*/
|
||||
|
||||
'make_command' => [
|
||||
'type' => 'sfc', // Options: 'sfc', 'mfc', 'class'
|
||||
'emoji' => true, // Options: true, false
|
||||
'with' => [
|
||||
'js' => false,
|
||||
'css' => false,
|
||||
'test' => false,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Class Namespace
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This value sets the root class namespace for Livewire component classes in
|
||||
| your application. This value will change where component auto-discovery
|
||||
| finds components. It's also referenced by the file creation commands.
|
||||
|
|
||||
*/
|
||||
|
||||
'class_namespace' => 'App\\Livewire',
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Class Path
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This value is used to specify the path where Livewire component class files
|
||||
| are created when running creation commands like `artisan make:livewire`.
|
||||
| This path is customizable to match your projects directory structure.
|
||||
|
|
||||
*/
|
||||
|
||||
'class_path' => app_path('Livewire'),
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| View Path
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This value is used to specify where Livewire component Blade templates are
|
||||
| stored when running file creation commands like `artisan make:livewire`.
|
||||
| It is also used if you choose to omit a component's render() method.
|
||||
|
|
||||
*/
|
||||
|
||||
'view_path' => resource_path('views/livewire'),
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Temporary File Uploads
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| Livewire handles file uploads by storing uploads in a temporary directory
|
||||
| before the file is stored permanently. All file uploads are directed to
|
||||
| a global endpoint for temporary storage. You may configure this below:
|
||||
|
|
||||
*/
|
||||
|
||||
'temporary_file_upload' => [
|
||||
'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default'
|
||||
'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
|
||||
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
|
||||
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
|
||||
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
|
||||
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
|
||||
'mov', 'avi', 'wmv', 'mp3', 'm4a',
|
||||
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
|
||||
],
|
||||
'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated...
|
||||
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
|
||||
],
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Render On Redirect
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines if Livewire will run a component's `render()` method
|
||||
| after a redirect has been triggered using something like `redirect(...)`
|
||||
| Setting this to true will render the view once more before redirecting
|
||||
|
|
||||
*/
|
||||
|
||||
'render_on_redirect' => false,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Eloquent Model Binding
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| Previous versions of Livewire supported binding directly to eloquent model
|
||||
| properties using wire:model by default. However, this behavior has been
|
||||
| deemed too "magical" and has therefore been put under a feature flag.
|
||||
|
|
||||
*/
|
||||
|
||||
'legacy_model_binding' => false,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Auto-inject Frontend Assets
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| By default, Livewire automatically injects its JavaScript and CSS into the
|
||||
| <head> and <body> of pages containing Livewire components. By disabling
|
||||
| this behavior, you need to use @livewireStyles and @livewireScripts.
|
||||
|
|
||||
*/
|
||||
|
||||
'inject_assets' => true,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Navigate (SPA mode)
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| By adding `wire:navigate` to links in your Livewire application, Livewire
|
||||
| will prevent the default link handling and instead request those pages
|
||||
| via AJAX, creating an SPA-like effect. Configure this behavior here.
|
||||
|
|
||||
*/
|
||||
|
||||
'navigate' => [
|
||||
'show_progress_bar' => true,
|
||||
'progress_bar_color' => '#2299dd',
|
||||
],
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| HTML Morph Markers
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| Livewire intelligently "morphs" existing HTML into the newly rendered HTML
|
||||
| after each update. To make this process more reliable, Livewire injects
|
||||
| "markers" into the rendered Blade surrounding @if, @class & @foreach.
|
||||
|
|
||||
*/
|
||||
|
||||
'inject_morph_markers' => true,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Smart Wire Keys
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| Livewire uses loops and keys used within loops to generate smart keys that
|
||||
| are applied to nested components that don't have them. This makes using
|
||||
| nested components more reliable by ensuring that they all have keys.
|
||||
|
|
||||
*/
|
||||
|
||||
'smart_wire_keys' => true,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Pagination Theme
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| When enabling Livewire's pagination feature by using the `WithPagination`
|
||||
| trait, Livewire will use Tailwind templates to render pagination views
|
||||
| on the page. If you want Bootstrap CSS, you can specify: "bootstrap"
|
||||
|
|
||||
*/
|
||||
|
||||
'pagination_theme' => 'tailwind',
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Release Token
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This token is stored client-side and sent along with each request to check
|
||||
| a users session to see if a new release has invalidated it. If there is
|
||||
| a mismatch it will throw an error and prompt for a browser refresh.
|
||||
|
|
||||
*/
|
||||
|
||||
'release_token' => 'a',
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| CSP Safe
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This config is used to determine if Livewire will use the CSP-safe version
|
||||
| of Alpine in its bundle. This is useful for applications that are using
|
||||
| strict Content Security Policy (CSP) to protect against XSS attacks.
|
||||
|
|
||||
*/
|
||||
|
||||
'csp_safe' => false,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Payload Guards
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| These settings protect against malicious or oversized payloads that could
|
||||
| cause denial of service. The default values should feel reasonable for
|
||||
| most web applications. Each can be set to null to disable the limit.
|
||||
|
|
||||
*/
|
||||
|
||||
'payload' => [
|
||||
'max_size' => 1024 * 1024, // 1MB - maximum request payload size in bytes
|
||||
'max_nesting_depth' => 10, // Maximum depth of dot-notation property paths
|
||||
'max_calls' => 50, // Maximum method calls per request
|
||||
'max_components' => 200, // Maximum components per batch request
|
||||
],
|
||||
];
|
||||
132
src/config/logging.php
Normal file
132
src/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', env('APP_NAME', 'Laravel')),
|
||||
'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
src/config/mail.php
Normal file
118
src/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', env('APP_NAME', 'Laravel')),
|
||||
],
|
||||
|
||||
];
|
||||
129
src/config/queue.php
Normal file
129
src/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',
|
||||
],
|
||||
|
||||
];
|
||||
87
src/config/sanctum.php
Normal file
87
src/config/sanctum.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Stateful Domains
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Requests from the following domains / hosts will receive stateful API
|
||||
| authentication cookies. Typically, these should include your local
|
||||
| and production domains which access your API via a frontend SPA.
|
||||
|
|
||||
*/
|
||||
|
||||
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
|
||||
'%s%s',
|
||||
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
|
||||
Sanctum::currentApplicationUrlWithPort(),
|
||||
// Sanctum::currentRequestHost(),
|
||||
))),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This array contains the authentication guards that will be checked when
|
||||
| Sanctum is trying to authenticate a request. If none of these guards
|
||||
| are able to authenticate the request, Sanctum will use the bearer
|
||||
| token that's present on an incoming request for authentication.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expiration Minutes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the number of minutes until an issued token will be
|
||||
| considered expired. This will override any values set in the token's
|
||||
| "expires_at" attribute, but first-party sessions are not affected.
|
||||
|
|
||||
*/
|
||||
|
||||
'expiration' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Token Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Sanctum can prefix new tokens in order to take advantage of numerous
|
||||
| security scanning initiatives maintained by open source platforms
|
||||
| that notify developers if they commit tokens into repositories.
|
||||
|
|
||||
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
||||
|
|
||||
*/
|
||||
|
||||
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When authenticating your first-party SPA with Sanctum you may need to
|
||||
| customize some of the middleware Sanctum uses while processing the
|
||||
| request. You may change the middleware listed below as required.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => [
|
||||
'authenticate_session' => AuthenticateSession::class,
|
||||
'encrypt_cookies' => EncryptCookies::class,
|
||||
'validate_csrf_token' => ValidateCsrfToken::class,
|
||||
],
|
||||
|
||||
];
|
||||
38
src/config/services.php
Normal file
38
src/config/services.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?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'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
233
src/config/session.php
Normal file
233
src/config/session.php
Normal file
@@ -0,0 +1,233 @@
|
||||
<?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),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Serialization
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the serialization strategy for session data, which
|
||||
| is JSON by default. Setting this to "php" allows the storage of PHP
|
||||
| objects in the session but can make an application vulnerable to
|
||||
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
||||
|
|
||||
| Supported: "json", "php"
|
||||
|
|
||||
*/
|
||||
|
||||
'serialization' => 'json',
|
||||
|
||||
];
|
||||
1
src/database/.gitignore
vendored
Normal file
1
src/database/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.sqlite*
|
||||
22
src/database/factories/ApiClientFactory.php
Normal file
22
src/database/factories/ApiClientFactory.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ApiClient;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<ApiClient>
|
||||
*/
|
||||
class ApiClientFactory extends Factory
|
||||
{
|
||||
protected $model = ApiClient::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->company().' Integration',
|
||||
'description' => fake()->sentence(),
|
||||
];
|
||||
}
|
||||
}
|
||||
46
src/database/factories/UserFactory.php
Normal file
46
src/database/factories/UserFactory.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<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'),
|
||||
'roles' => ['client'],
|
||||
'remember_token' => Str::random(10),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's email address should be unverified.
|
||||
*/
|
||||
public function unverified(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password')->nullable();
|
||||
$table->string('guid')->nullable()->unique();
|
||||
$table->string('domain')->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->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sessions');
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('users');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cache', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->mediumText('value');
|
||||
$table->bigInteger('expiration')->index();
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->bigInteger('expiration')->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cache');
|
||||
Schema::dropIfExists('cache_locks');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
$table->unsignedSmallInteger('attempts');
|
||||
$table->unsignedInteger('reserved_at')->nullable();
|
||||
$table->unsignedInteger('available_at');
|
||||
$table->unsignedInteger('created_at');
|
||||
});
|
||||
|
||||
Schema::create('job_batches', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->string('name');
|
||||
$table->integer('total_jobs');
|
||||
$table->integer('pending_jobs');
|
||||
$table->integer('failed_jobs');
|
||||
$table->longText('failed_job_ids');
|
||||
$table->mediumText('options')->nullable();
|
||||
$table->integer('cancelled_at')->nullable();
|
||||
$table->integer('created_at');
|
||||
$table->integer('finished_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->string('connection');
|
||||
$table->string('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
|
||||
$table->index(['connection', 'queue', 'failed_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
Schema::dropIfExists('job_batches');
|
||||
Schema::dropIfExists('jobs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('roles', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('key')->unique();
|
||||
$table->string('label');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('role_user', function (Blueprint $table) {
|
||||
$table->foreignId('role_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->primary(['role_id', 'user_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('role_user');
|
||||
Schema::dropIfExists('roles');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('teams', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('team_user', function (Blueprint $table) {
|
||||
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->primary(['team_id', 'user_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('team_user');
|
||||
Schema::dropIfExists('teams');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('categories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('subcategories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('category_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->string('default_priority_key')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('team_subcategory', function (Blueprint $table) {
|
||||
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('subcategory_id')->constrained()->cascadeOnDelete();
|
||||
$table->primary(['team_id', 'subcategory_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('team_subcategory');
|
||||
Schema::dropIfExists('subcategories');
|
||||
Schema::dropIfExists('categories');
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user