Compare commits
91
Commits
149748b03e
...
v0.2.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b226ee8d48 | ||
|
|
ac553f0087 | ||
|
|
28d20882ac | ||
|
|
208989d5fc | ||
|
|
d2f659de8f | ||
|
|
f461439ce6 | ||
|
|
3eea473336 | ||
|
|
bfec4f464e | ||
|
|
2edee702d4 | ||
|
|
9b90c24224 | ||
|
|
3869e5b8a6 | ||
|
|
196623bb67 | ||
|
|
bc6a113acf | ||
|
|
1377ee8510 | ||
|
|
2409c3b18f | ||
|
|
f61298fda4 | ||
|
|
7a4c6f3865 | ||
|
|
79c78b3434 | ||
|
|
38438b4700 | ||
|
|
fed0b657c0 | ||
|
|
1fe0c0b2d9 | ||
|
|
463cb7fe36 | ||
|
|
3bf5a0c508 | ||
|
|
32de1f425a | ||
|
|
1af98cd56e | ||
|
|
aadf159ac5 | ||
|
|
5c47453d4d | ||
|
|
ba7e174e82 | ||
|
|
d646f8b537 | ||
|
|
509d7abf82 | ||
|
|
5cc1840592 | ||
|
|
11b06d9b8c | ||
|
|
b76b4adaec | ||
|
|
c8fa76e08a | ||
|
|
87ae19f972 | ||
|
|
a97451edfa | ||
|
|
17b3e02bd8 | ||
|
|
d58fbd21ad | ||
|
|
002de1b293 | ||
|
|
707ebe6e48 | ||
|
|
4e29fc4526 | ||
|
|
fab1e9c7a8 | ||
|
|
ccd199a695 | ||
|
|
f7b50e8715 | ||
|
|
5147c8ed8d | ||
|
|
3914fd5432 | ||
|
|
4d6da2c5f5 | ||
|
|
313e2d2396 | ||
|
|
810d1850ae | ||
|
|
cd7fae3841 | ||
|
|
32e3496248 | ||
|
|
0e78be0eae | ||
|
|
17caabbb3e | ||
|
|
3f4945c20d | ||
|
|
65bf75ed77 | ||
|
|
802294d6a2 | ||
|
|
5e52e4fe78 | ||
|
|
f4a8bdd134 | ||
|
|
850ae337ca | ||
|
|
4adda6e53b | ||
|
|
de6f4e5b2f | ||
|
|
2e2ddd5b74 | ||
|
|
c58196293f | ||
|
|
8657d75682 | ||
|
|
71d9d1b63e | ||
|
|
68995a9f4c | ||
|
|
3e998a483e | ||
|
|
ceffe4f4a4 | ||
|
|
ce01b8ec55 | ||
|
|
bd774e20f6 | ||
|
|
d9cb8d0903 | ||
|
|
19e8ba89bf | ||
|
|
642e49d7b0 | ||
|
|
1d8fff1d51 | ||
|
|
bd7be4e41e | ||
|
|
9895fc36db | ||
|
|
fed6a282a4 | ||
|
|
db8855a056 | ||
|
|
04b0d5e316 | ||
|
|
49e887753c | ||
|
|
b8a1d65122 | ||
|
|
831014f7fe | ||
|
|
fef82cb88f | ||
|
|
40f9b473fc | ||
|
|
5abfae2736 | ||
|
|
18d19fa473 | ||
|
|
7331c122b5 | ||
|
|
a731f9ea10 | ||
|
|
afe99ea6d4 | ||
|
|
e8ee23371b | ||
|
|
96df45456e |
@@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
name: mcp-development
|
||||||
|
description: "Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# MCP Development
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Laravel MCP patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
Register MCP servers in `routes/ai.php`:
|
||||||
|
|
||||||
|
<!-- Register MCP Server -->
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Facades\Mcp;
|
||||||
|
|
||||||
|
Mcp::web();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating MCP Primitives
|
||||||
|
|
||||||
|
Create MCP tools, resources, prompts, and servers using artisan commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan make:mcp-tool ToolName # Create a tool
|
||||||
|
|
||||||
|
php artisan make:mcp-resource ResourceName # Create a resource
|
||||||
|
|
||||||
|
php artisan make:mcp-prompt PromptName # Create a prompt
|
||||||
|
|
||||||
|
php artisan make:mcp-server ServerName # Create a server
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties.
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
|
<!-- MCP Tool Example -->
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server\Tool;
|
||||||
|
use Laravel\Mcp\Server\Request;
|
||||||
|
use Laravel\Mcp\Server\Response;
|
||||||
|
|
||||||
|
class MyTool extends Tool
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return new Response(['result' => 'success']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registering Primitives in a Server
|
||||||
|
|
||||||
|
Each MCP server must explicitly declare the tools, resources, and prompts it exposes.
|
||||||
|
|
||||||
|
<!-- Register Primitives in MCP Server -->
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server;
|
||||||
|
|
||||||
|
class AppServer extends Server
|
||||||
|
{
|
||||||
|
protected array $tools = [
|
||||||
|
\App\Mcp\Tools\MyTool::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected array $resources = [
|
||||||
|
\App\Mcp\Resources\MyResource::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected array $prompts = [
|
||||||
|
\App\Mcp\Prompts\MyPrompt::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Check `routes/ai.php` for proper registration
|
||||||
|
2. Test tool via MCP client
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Running `mcp:start` command (it hangs waiting for input)
|
||||||
|
- Using HTTPS locally with Node-based MCP clients
|
||||||
|
- Not using `search-docs` for the latest MCP documentation
|
||||||
|
- Not registering MCP server routes in `routes/ai.php`
|
||||||
|
- Do not register `ai.php` in `bootstrap.php`; it is registered automatically.
|
||||||
|
- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config
|
||||||
+6
-10
@@ -25,7 +25,6 @@
|
|||||||
**/compose.*.y*ml
|
**/compose.*.y*ml
|
||||||
!**/composer.json
|
!**/composer.json
|
||||||
!**/composer.lock
|
!**/composer.lock
|
||||||
**/Dockerfile*
|
|
||||||
**/node_modules
|
**/node_modules
|
||||||
**/npm-debug.log
|
**/npm-debug.log
|
||||||
**/obj
|
**/obj
|
||||||
@@ -34,16 +33,13 @@
|
|||||||
vendor
|
vendor
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
.editorconfig
|
.editorconfig
|
||||||
.env.example
|
|
||||||
.env.mariadb
|
|
||||||
.env.mariadb.example
|
|
||||||
.env.production
|
.env.production
|
||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
|
|
||||||
# Ignore development-related files and directories
|
# Runtime-generated data must not become part of an image layer.
|
||||||
# For development environment, we will use mount bind to mount the source code to the container
|
storage/framework/cache/data/*
|
||||||
# So we don't need to copy the source code to the container for development
|
storage/framework/sessions/*
|
||||||
tests/
|
storage/framework/testing/*
|
||||||
phpunit.xml
|
storage/framework/views/*
|
||||||
phpunit.xml.dist
|
storage/logs/*
|
||||||
|
|||||||
+17
-1
@@ -10,7 +10,7 @@ APP_LOCALE=en
|
|||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
APP_FAKER_LOCALE=en_US
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
LOG_STACK=daily
|
LOG_STACK=stderr
|
||||||
|
|
||||||
# Optional Discord error logging (append `,discord_errors` to LOG_STACK to enable it)
|
# Optional Discord error logging (append `,discord_errors` to LOG_STACK to enable it)
|
||||||
LOG_DISCORD_WEBHOOK_URL=
|
LOG_DISCORD_WEBHOOK_URL=
|
||||||
@@ -38,6 +38,22 @@ MAIL_ENCRYPTION=tls
|
|||||||
MAIL_FROM_ADDRESS=no-reply@example.com
|
MAIL_FROM_ADDRESS=no-reply@example.com
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
CACHE_STORE=database
|
||||||
|
QUEUE_CONNECTION=database
|
||||||
|
SESSION_DRIVER=database
|
||||||
|
|
||||||
FILESYSTEM_DISK=public
|
FILESYSTEM_DISK=public
|
||||||
|
AVATAR_FILESYSTEM_DISK=public
|
||||||
|
AVATAR_URLS_TEMPORARY=false
|
||||||
|
AVATAR_URL_EXPIRATION_MINUTES=15
|
||||||
|
|
||||||
|
# Production: set AVATAR_FILESYSTEM_DISK=s3 and inject these as runtime secrets.
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
AWS_DEFAULT_REGION=eu-central-1
|
||||||
|
AWS_BUCKET=
|
||||||
|
AWS_URL=
|
||||||
|
AWS_ENDPOINT=
|
||||||
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
MARIADB_DATABASE=kanbangit
|
|
||||||
MARIADB_USER=kanbangit
|
|
||||||
MARIADB_PASSWORD=
|
|
||||||
MARIADB_ROOT_PASSWORD=
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
name: test_and_check_app
|
||||||
|
description: Build the test image and run all application checks
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Test and check app
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
docker buildx build \
|
||||||
|
--file Dockerfile \
|
||||||
|
--target test \
|
||||||
|
--tag "local/kanbangit-test:${GITHUB_SHA}" \
|
||||||
|
--progress plain \
|
||||||
|
--load \
|
||||||
|
.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
name: CI/CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.twyco.de
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
preconditions:
|
||||||
|
if: >
|
||||||
|
!(contains(github.event.head_commit.message, '[no-ci]') || contains(github.event.head_commit.message, '[skip-ci]'))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Conditions met
|
||||||
|
run: |
|
||||||
|
echo "Conditions for CI/CD are met. Proceeding with the workflow."
|
||||||
|
|
||||||
|
test_and_check_app:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- preconditions
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
|
|
||||||
|
- name: Run required application checks
|
||||||
|
uses: ./.github/tests/test_and_check_app
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish immutable image
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'push' &&
|
||||||
|
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
|
||||||
|
needs:
|
||||||
|
- preconditions
|
||||||
|
- test_and_check_app
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Validate ref and calculate image name
|
||||||
|
id: image
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
image_path="$(printf '%s' "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
image="${REGISTRY}/${image_path}"
|
||||||
|
|
||||||
|
case "${GITHUB_REF}" in
|
||||||
|
refs/heads/main)
|
||||||
|
printf -v run_attempt '%03d' "${GITHUB_RUN_ATTEMPT}"
|
||||||
|
tag="main-${GITHUB_RUN_NUMBER}-${run_attempt}-${GITHUB_SHA:0:12}"
|
||||||
|
environment="staging"
|
||||||
|
;;
|
||||||
|
refs/tags/*)
|
||||||
|
tag="${GITHUB_REF#refs/tags/}"
|
||||||
|
|
||||||
|
if [[ ! "${tag}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
||||||
|
echo "Production tags must match vMAJOR.MINOR.PATCH." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git fetch --no-tags origin main:refs/remotes/origin/main
|
||||||
|
|
||||||
|
if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/main; then
|
||||||
|
echo "Production tag must point to a commit contained in main." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
environment="production"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported ref: ${GITHUB_REF}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "image=${image}"
|
||||||
|
echo "tag=${tag}"
|
||||||
|
echo "environment=${environment}"
|
||||||
|
} >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Log in to private registry
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
if [[ -z "${REGISTRY_USERNAME}" || -z "${REGISTRY_TOKEN}" ]]; then
|
||||||
|
echo "Registry credentials are missing." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "${REGISTRY_TOKEN}" |
|
||||||
|
docker login \
|
||||||
|
--username "${REGISTRY_USERNAME}" \
|
||||||
|
--password-stdin \
|
||||||
|
"${REGISTRY}"
|
||||||
|
|
||||||
|
- name: Build and publish runtime image
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
IMAGE: ${{ steps.image.outputs.image }}
|
||||||
|
IMAGE_TAG: ${{ steps.image.outputs.tag }}
|
||||||
|
DEPLOYMENT_ENVIRONMENT: ${{ steps.image.outputs.environment }}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
if [[ "${DEPLOYMENT_ENVIRONMENT}" == "production" ]] &&
|
||||||
|
docker buildx imagetools inspect "${IMAGE}:${IMAGE_TAG}" >/dev/null 2>&1; then
|
||||||
|
echo "Production image ${IMAGE}:${IMAGE_TAG} already exists and will not be overwritten." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker buildx build \
|
||||||
|
--file Dockerfile \
|
||||||
|
--target runtime \
|
||||||
|
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
--label "org.opencontainers.image.revision=${GITHUB_SHA}" \
|
||||||
|
--label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \
|
||||||
|
--label "de.twyco.environment=${DEPLOYMENT_ENVIRONMENT}" \
|
||||||
|
--tag "${IMAGE}:${IMAGE_TAG}" \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Remove registry credentials
|
||||||
|
if: ${{ always() }}
|
||||||
|
shell: bash
|
||||||
|
run: docker logout "${REGISTRY}" >/dev/null 2>&1 || true
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
name: linter
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- staging
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
quality:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: '8.4'
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: |
|
|
||||||
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
|
||||||
npm install
|
|
||||||
|
|
||||||
- name: Run Pint
|
|
||||||
run: composer lint
|
|
||||||
|
|
||||||
- name: Lint Frontend
|
|
||||||
run: npm run lint
|
|
||||||
|
|
||||||
# - name: Commit Changes
|
|
||||||
# uses: stefanzweifel/git-auto-commit-action@v7
|
|
||||||
# with:
|
|
||||||
# commit_message: fix code style
|
|
||||||
# commit_options: '--no-verify'
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
name: tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- staging
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ci:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
php-version: ['8.4']
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: ${{ matrix.php-version }}
|
|
||||||
tools: composer:v2
|
|
||||||
coverage: xdebug
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
|
|
||||||
- name: Install Node Dependencies
|
|
||||||
run: npm i
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
|
||||||
|
|
||||||
- name: Copy Environment File
|
|
||||||
run: cp .env.example .env
|
|
||||||
|
|
||||||
- name: Generate Application Key
|
|
||||||
run: php artisan key:generate
|
|
||||||
|
|
||||||
- name: Build Assets
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Tests
|
|
||||||
run: ./vendor/bin/phpunit
|
|
||||||
@@ -13,10 +13,10 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v2
|
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v2
|
||||||
- laravel/fortify (FORTIFY) - v1
|
- laravel/fortify (FORTIFY) - v1
|
||||||
- laravel/framework (LARAVEL) - v13
|
- laravel/framework (LARAVEL) - v13
|
||||||
|
- laravel/mcp (MCP) - v0
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
- laravel/wayfinder (WAYFINDER) - v0
|
- laravel/wayfinder (WAYFINDER) - v0
|
||||||
- laravel/boost (BOOST) - v2
|
- laravel/boost (BOOST) - v2
|
||||||
- laravel/mcp (MCP) - v0
|
|
||||||
- laravel/pail (PAIL) - v1
|
- laravel/pail (PAIL) - v1
|
||||||
- laravel/pint (PINT) - v1
|
- laravel/pint (PINT) - v1
|
||||||
- laravel/sail (SAIL) - v1
|
- laravel/sail (SAIL) - v1
|
||||||
@@ -34,7 +34,7 @@ This project has domain-specific skills available. You MUST activate the relevan
|
|||||||
|
|
||||||
- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
|
- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
|
||||||
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
|
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
|
||||||
- `project-route-structure` — Always apply in this project when creating, moving, or refactoring Laravel route definitions in routes/**/*.php; changing route prefixes, names, middleware, scoped bindings, or route files; or adding frontend-facing endpoints that Wayfinder generates. Documents the KanbanGit route-file layout and grouping conventions.
|
- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP.
|
||||||
- `wayfinder-development` — Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task
|
- `wayfinder-development` — Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task
|
||||||
- `inertia-vue-development` — Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation.
|
- `inertia-vue-development` — Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation.
|
||||||
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
|
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
|
||||||
|
|||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
ARG COMPOSER_IMAGE=composer:2.9.5@sha256:698d3801b2a622ace460c4743c781282fcbcb733a4cbf8b31c44731e846585e8
|
||||||
|
ARG NODE_IMAGE=node:22-bookworm-slim@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436
|
||||||
|
ARG PHP_IMAGE=php:8.4-fpm-bookworm@sha256:c5fb7a0c02f4efe280691910c8b734995fa83598cdcf3115ef5dcb2e4617681c
|
||||||
|
|
||||||
|
FROM ${COMPOSER_IMAGE} AS composer_binary
|
||||||
|
FROM ${NODE_IMAGE} AS node_binary
|
||||||
|
|
||||||
|
FROM ${PHP_IMAGE} AS php_base
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
gettext-base \
|
||||||
|
git \
|
||||||
|
libfcgi-bin \
|
||||||
|
libfreetype6-dev \
|
||||||
|
libicu-dev \
|
||||||
|
libjpeg62-turbo-dev \
|
||||||
|
libmagickwand-dev \
|
||||||
|
libonig-dev \
|
||||||
|
libpng-dev \
|
||||||
|
libpq-dev \
|
||||||
|
libsqlite3-dev \
|
||||||
|
libssl-dev \
|
||||||
|
libxml2-dev \
|
||||||
|
libzip-dev \
|
||||||
|
nginx \
|
||||||
|
procps \
|
||||||
|
unzip \
|
||||||
|
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||||
|
&& docker-php-ext-install -j"$(nproc)" \
|
||||||
|
bcmath \
|
||||||
|
gd \
|
||||||
|
intl \
|
||||||
|
opcache \
|
||||||
|
pcntl \
|
||||||
|
pdo_mysql \
|
||||||
|
pdo_pgsql \
|
||||||
|
pdo_sqlite \
|
||||||
|
pgsql \
|
||||||
|
soap \
|
||||||
|
zip \
|
||||||
|
&& pecl install imagick \
|
||||||
|
&& docker-php-ext-enable imagick \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
|
||||||
|
COPY --from=composer_binary /usr/bin/composer /usr/local/bin/composer
|
||||||
|
|
||||||
|
WORKDIR /var/www
|
||||||
|
|
||||||
|
FROM php_base AS build
|
||||||
|
|
||||||
|
COPY --from=node_binary /usr/local/ /usr/local/
|
||||||
|
COPY . /var/www
|
||||||
|
|
||||||
|
RUN composer install --prefer-dist --no-interaction --no-progress \
|
||||||
|
&& corepack enable pnpm \
|
||||||
|
&& pnpm install --frozen-lockfile \
|
||||||
|
&& pnpm run build
|
||||||
|
|
||||||
|
FROM build AS test
|
||||||
|
|
||||||
|
RUN cp .env.example .env \
|
||||||
|
&& php artisan key:generate\
|
||||||
|
&& composer audit --locked --no-interaction \
|
||||||
|
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
|
||||||
|
|
||||||
|
FROM build AS production_builder
|
||||||
|
|
||||||
|
RUN composer install \
|
||||||
|
--classmap-authoritative \
|
||||||
|
--no-dev \
|
||||||
|
--no-interaction \
|
||||||
|
--no-progress \
|
||||||
|
--prefer-dist \
|
||||||
|
&& rm -rf node_modules tests phpunit.xml
|
||||||
|
|
||||||
|
FROM php_base AS runtime
|
||||||
|
|
||||||
|
COPY docker/entrypoint.sh /usr/local/bin/kanbangit-entrypoint
|
||||||
|
COPY docker/nginx/nginx.conf.template /etc/nginx/nginx.conf.template
|
||||||
|
COPY docker/php-fpm-healthcheck.sh /usr/local/bin/php-fpm-healthcheck
|
||||||
|
COPY --from=production_builder --chown=www-data:www-data /var/www /var/www
|
||||||
|
|
||||||
|
RUN chmod +x /usr/local/bin/kanbangit-entrypoint /usr/local/bin/php-fpm-healthcheck \
|
||||||
|
&& mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \
|
||||||
|
&& printf '%s\n' \
|
||||||
|
'[global]' \
|
||||||
|
'daemonize = no' \
|
||||||
|
'' \
|
||||||
|
'[www]' \
|
||||||
|
'pm.status_path = /status' \
|
||||||
|
> /usr/local/etc/php-fpm.d/zz-kanbangit.conf \
|
||||||
|
&& mkdir -p \
|
||||||
|
/var/www/bootstrap/cache \
|
||||||
|
/var/www/storage/app/public \
|
||||||
|
/var/www/storage/framework/cache \
|
||||||
|
/var/www/storage/framework/sessions \
|
||||||
|
/var/www/storage/framework/testing \
|
||||||
|
/var/www/storage/framework/views \
|
||||||
|
/var/www/storage/logs \
|
||||||
|
&& ln -s ../storage/app/public /var/www/public/storage \
|
||||||
|
&& chown -R www-data:www-data /var/www/bootstrap/cache /var/www/storage
|
||||||
|
|
||||||
|
USER www-data
|
||||||
|
|
||||||
|
EXPOSE 8080 9000
|
||||||
|
|
||||||
|
ENTRYPOINT ["kanbangit-entrypoint"]
|
||||||
|
CMD ["php-fpm"]
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Actions\Notifications;
|
namespace App\Actions\Notifications;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Actions\Projects\ResolveProjectTemplateState;
|
||||||
use App\Enums\DiscordAllowedMentionType;
|
use App\Enums\DiscordAllowedMentionType;
|
||||||
use App\Enums\NotificationChannel;
|
use App\Enums\NotificationChannel;
|
||||||
use App\Enums\NotificationEventType;
|
use App\Enums\NotificationEventType;
|
||||||
@@ -15,6 +17,7 @@ class BuildProjectNotificationSettingsPayload
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ListProjectNotificationActions $listProjectNotificationActions,
|
private readonly ListProjectNotificationActions $listProjectNotificationActions,
|
||||||
|
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,6 +36,9 @@ class BuildProjectNotificationSettingsPayload
|
|||||||
)->resolve(),
|
)->resolve(),
|
||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
|
'templateState' => $this->resolveProjectTemplateState->handle($project, [
|
||||||
|
DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS,
|
||||||
|
]),
|
||||||
'templateVariables' => $this->templateVariables(),
|
'templateVariables' => $this->templateVariables(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,19 @@
|
|||||||
|
|
||||||
namespace App\Actions\ProjectBoard;
|
namespace App\Actions\ProjectBoard;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||||
|
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||||
use App\Models\TicketStatus;
|
use App\Models\TicketStatus;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
class UpdateTicketStatusTransitions
|
class UpdateTicketStatusTransitions
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||||
|
private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{allowed_ticket_status_ids?: array<int, int|string>|null} $validated
|
* @param array{allowed_ticket_status_ids?: array<int, int|string>|null} $validated
|
||||||
*/
|
*/
|
||||||
@@ -19,6 +27,14 @@ class UpdateTicketStatusTransitions
|
|||||||
->all();
|
->all();
|
||||||
|
|
||||||
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
|
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
|
||||||
|
$this->projectTemplateOverrideManager->mark(
|
||||||
|
$ticketStatus->project,
|
||||||
|
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
||||||
|
);
|
||||||
|
$this->refreshProjectBaseTemplate->handle(
|
||||||
|
$ticketStatus->project,
|
||||||
|
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
||||||
|
);
|
||||||
|
|
||||||
return $ticketStatus->fresh();
|
return $ticketStatus->fresh();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,19 @@
|
|||||||
|
|
||||||
namespace App\Actions\ProjectTicketMetadata;
|
namespace App\Actions\ProjectTicketMetadata;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||||
|
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
class UpdateProjectTicketMetadataSettings
|
class UpdateProjectTicketMetadataSettings
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||||
|
private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{show_author?: bool|null, show_story_points?: bool|null} $validated
|
* @param array{show_author?: bool|null, show_story_points?: bool|null} $validated
|
||||||
*/
|
*/
|
||||||
@@ -16,6 +24,14 @@ class UpdateProjectTicketMetadataSettings
|
|||||||
'show_author' => Arr::get($validated, 'show_author', false),
|
'show_author' => Arr::get($validated, 'show_author', false),
|
||||||
'show_story_points' => Arr::get($validated, 'show_story_points', false),
|
'show_story_points' => Arr::get($validated, 'show_story_points', false),
|
||||||
]);
|
]);
|
||||||
|
$this->projectTemplateOverrideManager->mark(
|
||||||
|
$project,
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||||
|
);
|
||||||
|
$this->refreshProjectBaseTemplate->handle(
|
||||||
|
$project,
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||||
|
);
|
||||||
|
|
||||||
return $project->fresh();
|
return $project->fresh();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class BuildProjectBacklogPayload
|
|||||||
'tickets' => TicketResource::collection(
|
'tickets' => TicketResource::collection(
|
||||||
Ticket::query()
|
Ticket::query()
|
||||||
->with([
|
->with([
|
||||||
|
'assignee',
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class BuildProjectBoardPayload
|
|||||||
'ticketStatuses',
|
'ticketStatuses',
|
||||||
'tickets' => fn ($query) => $query
|
'tickets' => fn ($query) => $query
|
||||||
->with([
|
->with([
|
||||||
|
'assignee',
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ use App\Models\Project;
|
|||||||
|
|
||||||
class BuildProjectBoardSettingsPayload
|
class BuildProjectBoardSettingsPayload
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -27,6 +31,9 @@ class BuildProjectBoardSettingsPayload
|
|||||||
)->resolve(),
|
)->resolve(),
|
||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
|
'templateState' => $this->resolveProjectTemplateState->handle($project, [
|
||||||
|
DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW,
|
||||||
|
]),
|
||||||
'ticketStatuses' => TicketStatusResource::collection(
|
'ticketStatuses' => TicketStatusResource::collection(
|
||||||
$project->ticketStatuses()
|
$project->ticketStatuses()
|
||||||
->with(['allowedTargetStatuses.boardColumn', 'boardColumn'])
|
->with(['allowedTargetStatuses.boardColumn', 'boardColumn'])
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ use App\Models\TicketFieldDefinition;
|
|||||||
|
|
||||||
class BuildProjectTicketMetadataSettingsPayload
|
class BuildProjectTicketMetadataSettingsPayload
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -23,6 +27,13 @@ class BuildProjectTicketMetadataSettingsPayload
|
|||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
'error' => $error,
|
'error' => $error,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
|
'templateState' => $this->resolveProjectTemplateState->handle($project, [
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_PRIORITIES,
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
|
||||||
|
]),
|
||||||
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(),
|
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(),
|
||||||
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(),
|
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(),
|
||||||
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(),
|
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(),
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ use App\Http\Resources\TicketPriorityResource;
|
|||||||
use App\Http\Resources\TicketReferenceResource;
|
use App\Http\Resources\TicketReferenceResource;
|
||||||
use App\Http\Resources\TicketStatusResource;
|
use App\Http\Resources\TicketStatusResource;
|
||||||
use App\Http\Resources\TicketTypeResource;
|
use App\Http\Resources\TicketTypeResource;
|
||||||
use App\Models\Ticket;
|
|
||||||
use App\Http\Resources\UserResource;
|
use App\Http\Resources\UserResource;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\Ticket;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
||||||
class BuildProjectTicketOptionsPayload
|
class BuildProjectTicketOptionsPayload
|
||||||
|
|||||||
@@ -6,25 +6,85 @@ use App\Actions\Permissions\PermissionService;
|
|||||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class CreateProject
|
class CreateProject
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
private readonly CreateProjectBaseTemplate $createProjectBaseTemplate,
|
||||||
|
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||||
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
|
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
|
||||||
private readonly PermissionService $permissionService,
|
private readonly PermissionService $permissionService,
|
||||||
|
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{key: string, name: string} $attributes
|
* @param array{key: string, name: string, creation_mode?: string, source_project_id?: int|string|null, source_template_id?: int|string|null, copy_areas?: array<int, string>|null} $attributes
|
||||||
*/
|
*/
|
||||||
public function handle(array $attributes, User $actor): Project
|
public function handle(array $attributes, User $actor): Project
|
||||||
{
|
{
|
||||||
$project = Project::query()->create($attributes);
|
return DB::transaction(function () use ($attributes, $actor): Project {
|
||||||
|
$project = Project::query()->create(Arr::only($attributes, ['key', 'name']));
|
||||||
|
$creationMode = Arr::get($attributes, 'creation_mode', 'scratch');
|
||||||
|
$sourceTemplateId = Arr::get($attributes, 'source_template_id');
|
||||||
|
$baseTemplate = null;
|
||||||
|
$sourceTemplate = null;
|
||||||
|
|
||||||
$this->initializeProjectTicketMetadata->handle($project);
|
if ($creationMode === 'template' && $sourceTemplateId !== null) {
|
||||||
|
$sourceTemplate = ProjectTemplate::query()
|
||||||
|
->published()
|
||||||
|
->findOrFail((int) $sourceTemplateId);
|
||||||
|
|
||||||
|
$project->forceFill([
|
||||||
|
'project_template_id' => $sourceTemplate->id,
|
||||||
|
'template_overrides' => [],
|
||||||
|
])->saveQuietly();
|
||||||
|
} else {
|
||||||
|
$baseTemplate = $this->createProjectBaseTemplate->handle($project);
|
||||||
|
|
||||||
|
$project->forceFill([
|
||||||
|
'project_template_id' => $baseTemplate->id,
|
||||||
|
'template_overrides' => [],
|
||||||
|
])->saveQuietly();
|
||||||
|
}
|
||||||
|
|
||||||
|
$project = $project->fresh() ?? $project;
|
||||||
|
|
||||||
|
$this->projectTemplateOverrideManager->withoutRecording(
|
||||||
|
fn () => $this->initializeProjectTicketMetadata->handle($project),
|
||||||
|
);
|
||||||
$this->permissionService->grant($actor, Permission::ProjectAdmin, $project);
|
$this->permissionService->grant($actor, Permission::ProjectAdmin, $project);
|
||||||
|
|
||||||
return $project;
|
if ($creationMode === 'duplicate' && Arr::get($attributes, 'source_project_id') !== null) {
|
||||||
|
$sourceProject = Project::query()->findOrFail((int) Arr::get($attributes, 'source_project_id'));
|
||||||
|
|
||||||
|
$this->duplicateProjectConfiguration->handle(
|
||||||
|
$sourceProject,
|
||||||
|
$project,
|
||||||
|
array_values(Arr::get($attributes, 'copy_areas', [])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($creationMode === 'template' && $sourceTemplate !== null) {
|
||||||
|
$this->duplicateProjectConfiguration->apply(
|
||||||
|
$project->fresh() ?? $project,
|
||||||
|
$sourceTemplate->configuration_snapshot ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($baseTemplate !== null) {
|
||||||
|
$baseTemplate->forceFill([
|
||||||
|
'configuration_snapshot' => $this->duplicateProjectConfiguration->export(
|
||||||
|
$project->fresh() ?? $project,
|
||||||
|
DuplicateProjectConfiguration::copyableAreas(),
|
||||||
|
),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $project->fresh() ?? $project;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
|
||||||
|
class CreateProjectBaseTemplate
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(Project $project): ProjectTemplate
|
||||||
|
{
|
||||||
|
$copyAreas = DuplicateProjectConfiguration::copyableAreas();
|
||||||
|
|
||||||
|
return ProjectTemplate::query()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'source_project_id' => $project->id,
|
||||||
|
'name' => "{$project->name} base template",
|
||||||
|
'slug' => strtolower($project->key).'-base-template',
|
||||||
|
'is_published' => false,
|
||||||
|
'copy_areas' => $copyAreas,
|
||||||
|
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, $copyAreas),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class CreateProjectTemplate
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{name: string, slug: string, copy_areas?: array<int, string>} $attributes
|
||||||
|
*/
|
||||||
|
public function handle(Project $sourceProject, array $attributes): ProjectTemplate
|
||||||
|
{
|
||||||
|
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
|
||||||
|
|
||||||
|
return ProjectTemplate::query()->create([
|
||||||
|
'project_id' => null,
|
||||||
|
'source_project_id' => $sourceProject->id,
|
||||||
|
'name' => $attributes['name'],
|
||||||
|
'slug' => $attributes['slug'],
|
||||||
|
'is_published' => true,
|
||||||
|
'copy_areas' => $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas),
|
||||||
|
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class DuplicateProjectConfiguration
|
||||||
|
{
|
||||||
|
public const COPY_PROJECT_SETTINGS = 'project_settings';
|
||||||
|
|
||||||
|
public const COPY_BOARD_COLUMNS = 'board_columns';
|
||||||
|
|
||||||
|
public const COPY_TICKET_STATUSES = 'ticket_statuses';
|
||||||
|
|
||||||
|
public const COPY_STATUS_TRANSITIONS = 'status_transitions';
|
||||||
|
|
||||||
|
public const COPY_TICKET_TYPES = 'ticket_types';
|
||||||
|
|
||||||
|
public const COPY_TICKET_PRIORITIES = 'ticket_priorities';
|
||||||
|
|
||||||
|
public const COPY_TICKET_FIELDS = 'ticket_fields';
|
||||||
|
|
||||||
|
public const COPY_TICKET_LINK_TYPES = 'ticket_link_types';
|
||||||
|
|
||||||
|
public const COPY_REPOSITORY_CONNECTIONS = 'repository_connections';
|
||||||
|
|
||||||
|
public const COPY_NOTIFICATION_ACTIONS = 'notification_actions';
|
||||||
|
|
||||||
|
public const COPY_BOARD_WORKFLOW = 'board_workflow';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function copyableAreas(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::COPY_PROJECT_SETTINGS,
|
||||||
|
self::COPY_BOARD_COLUMNS,
|
||||||
|
self::COPY_TICKET_STATUSES,
|
||||||
|
self::COPY_STATUS_TRANSITIONS,
|
||||||
|
self::COPY_TICKET_TYPES,
|
||||||
|
self::COPY_TICKET_PRIORITIES,
|
||||||
|
self::COPY_TICKET_FIELDS,
|
||||||
|
self::COPY_TICKET_LINK_TYPES,
|
||||||
|
self::COPY_REPOSITORY_CONNECTIONS,
|
||||||
|
self::COPY_NOTIFICATION_ACTIONS,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function acceptedCopyAreas(): array
|
||||||
|
{
|
||||||
|
return [...self::copyableAreas(), self::COPY_BOARD_WORKFLOW];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{value: string, label: string, description: string}> */
|
||||||
|
public static function copyAreaOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['value' => self::COPY_PROJECT_SETTINGS, 'label' => 'Project settings', 'description' => 'General project preferences like story points and author visibility.'],
|
||||||
|
['value' => self::COPY_BOARD_COLUMNS, 'label' => 'Board columns', 'description' => 'Columns and their order on the project board.'],
|
||||||
|
['value' => self::COPY_TICKET_STATUSES, 'label' => 'Ticket statuses', 'description' => 'Statuses and their assignment to board columns.'],
|
||||||
|
['value' => self::COPY_STATUS_TRANSITIONS, 'label' => 'Status transitions', 'description' => 'Allowed moves between ticket statuses.'],
|
||||||
|
['value' => self::COPY_TICKET_TYPES, 'label' => 'Ticket types', 'description' => 'Available ticket types, including parent types.'],
|
||||||
|
['value' => self::COPY_TICKET_PRIORITIES, 'label' => 'Ticket priorities', 'description' => 'Available ticket priorities.'],
|
||||||
|
['value' => self::COPY_TICKET_FIELDS, 'label' => 'Custom ticket fields', 'description' => 'Custom field definitions without ticket values.'],
|
||||||
|
['value' => self::COPY_TICKET_LINK_TYPES, 'label' => 'Ticket link types', 'description' => 'Relationship labels between tickets.'],
|
||||||
|
['value' => self::COPY_REPOSITORY_CONNECTIONS, 'label' => 'Repository connections', 'description' => 'Repositories linked to the project.'],
|
||||||
|
['value' => self::COPY_NOTIFICATION_ACTIONS, 'label' => 'Notification actions', 'description' => 'Notification rules and delivery settings.'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $copyAreas @return list<string> */
|
||||||
|
public function normalizeCopyAreas(array $copyAreas): array
|
||||||
|
{
|
||||||
|
if (in_array(self::COPY_BOARD_WORKFLOW, $copyAreas, true)) {
|
||||||
|
$copyAreas = [
|
||||||
|
...array_diff($copyAreas, [self::COPY_BOARD_WORKFLOW]),
|
||||||
|
self::COPY_BOARD_COLUMNS,
|
||||||
|
self::COPY_TICKET_STATUSES,
|
||||||
|
self::COPY_STATUS_TRANSITIONS,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_intersect(self::copyableAreas(), array_unique($copyAreas)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $configuration @return array<string, mixed> */
|
||||||
|
public function normalizeConfiguration(array $configuration): array
|
||||||
|
{
|
||||||
|
$workflow = $configuration[self::COPY_BOARD_WORKFLOW] ?? null;
|
||||||
|
|
||||||
|
if (is_array($workflow)) {
|
||||||
|
$statuses = $workflow['ticket_statuses'] ?? [];
|
||||||
|
$configuration[self::COPY_BOARD_COLUMNS] ??= $workflow['board_columns'] ?? [];
|
||||||
|
$configuration[self::COPY_TICKET_STATUSES] ??= array_map(
|
||||||
|
static fn (array $status): array => array_diff_key($status, ['allowed_target_indexes' => true]),
|
||||||
|
$statuses,
|
||||||
|
);
|
||||||
|
$configuration[self::COPY_STATUS_TRANSITIONS] ??= array_map(
|
||||||
|
static fn (array $status): array => $status['allowed_target_indexes'] ?? [],
|
||||||
|
$statuses,
|
||||||
|
);
|
||||||
|
unset($configuration[self::COPY_BOARD_WORKFLOW]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $copyAreas @return array<string, mixed> */
|
||||||
|
public function export(Project $sourceProject, array $copyAreas): array
|
||||||
|
{
|
||||||
|
$selectedAreas = $this->normalizeCopyAreas($copyAreas);
|
||||||
|
|
||||||
|
if ($selectedAreas === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceProject->load([
|
||||||
|
'boardColumns', 'notificationActions', 'repositoryConnections', 'ticketFieldDefinitions',
|
||||||
|
'ticketLinkTypes', 'ticketPriorities', 'ticketStatuses.allowedTargetStatuses', 'ticketTypes',
|
||||||
|
]);
|
||||||
|
$configuration = [];
|
||||||
|
|
||||||
|
if (in_array(self::COPY_PROJECT_SETTINGS, $selectedAreas, true)) {
|
||||||
|
$configuration[self::COPY_PROJECT_SETTINGS] = [
|
||||||
|
'show_author' => $sourceProject->show_author,
|
||||||
|
'show_story_points' => $sourceProject->show_story_points,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$columnIndexes = $sourceProject->boardColumns->values()->mapWithKeys(
|
||||||
|
static fn ($column, int $index): array => [$column->id => $index],
|
||||||
|
);
|
||||||
|
$statusIndexes = $sourceProject->ticketStatuses->values()->mapWithKeys(
|
||||||
|
static fn ($status, int $index): array => [$status->id => $index],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (in_array(self::COPY_BOARD_COLUMNS, $selectedAreas, true)) {
|
||||||
|
$configuration[self::COPY_BOARD_COLUMNS] = $sourceProject->boardColumns->values()->map(
|
||||||
|
static fn ($column): array => ['name' => $column->name, 'position' => $column->position],
|
||||||
|
)->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array(self::COPY_TICKET_STATUSES, $selectedAreas, true)) {
|
||||||
|
$configuration[self::COPY_TICKET_STATUSES] = $sourceProject->ticketStatuses->values()->map(
|
||||||
|
static fn ($status): array => [
|
||||||
|
'board_column_index' => $status->board_column_id === null ? null : $columnIndexes->get($status->board_column_id),
|
||||||
|
'name' => $status->name,
|
||||||
|
'position' => $status->position,
|
||||||
|
],
|
||||||
|
)->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array(self::COPY_STATUS_TRANSITIONS, $selectedAreas, true)) {
|
||||||
|
$configuration[self::COPY_STATUS_TRANSITIONS] = $sourceProject->ticketStatuses->values()->map(
|
||||||
|
static fn ($status): array => $status->allowedTargetStatuses->pluck('id')->map(
|
||||||
|
static fn (int $id): ?int => $statusIndexes->get($id),
|
||||||
|
)->filter(static fn (?int $index): bool => $index !== null)->values()->all(),
|
||||||
|
)->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
$simpleAreas = [
|
||||||
|
self::COPY_TICKET_TYPES => fn ($item): array => ['name' => $item->name, 'position' => $item->position, 'is_parent' => $item->is_parent],
|
||||||
|
self::COPY_TICKET_PRIORITIES => fn ($item): array => ['name' => $item->name, 'position' => $item->position],
|
||||||
|
self::COPY_TICKET_FIELDS => fn ($item): array => ['field_type' => $item->field_type, 'label' => $item->label, 'position' => $item->position],
|
||||||
|
self::COPY_TICKET_LINK_TYPES => fn ($item): array => ['label' => $item->label, 'counterpart_label' => $item->counterpart_label, 'position' => $item->position],
|
||||||
|
self::COPY_REPOSITORY_CONNECTIONS => fn ($item): array => [
|
||||||
|
'provider' => $item->provider->value, 'base_url' => $item->base_url,
|
||||||
|
'provider_repository_id' => $item->provider_repository_id, 'owner_name' => $item->owner_name,
|
||||||
|
'repository_name' => $item->repository_name, 'repository_full_name' => $item->repository_full_name,
|
||||||
|
'repository_url' => $item->repository_url,
|
||||||
|
],
|
||||||
|
self::COPY_NOTIFICATION_ACTIONS => fn ($item): array => [
|
||||||
|
'name' => $item->name, 'enabled' => $item->enabled, 'event_type' => $item->event_type->value,
|
||||||
|
'channel' => $item->channel->value, 'channel_configuration' => $item->channel_configuration ?? [],
|
||||||
|
'message_template' => $item->message_template, 'delivery_url' => $item->delivery_url,
|
||||||
|
'filters' => $item->filters ?? [],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$relations = [
|
||||||
|
self::COPY_TICKET_TYPES => 'ticketTypes', self::COPY_TICKET_PRIORITIES => 'ticketPriorities',
|
||||||
|
self::COPY_TICKET_FIELDS => 'ticketFieldDefinitions', self::COPY_TICKET_LINK_TYPES => 'ticketLinkTypes',
|
||||||
|
self::COPY_REPOSITORY_CONNECTIONS => 'repositoryConnections', self::COPY_NOTIFICATION_ACTIONS => 'notificationActions',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($simpleAreas as $area => $mapper) {
|
||||||
|
if (in_array($area, $selectedAreas, true)) {
|
||||||
|
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map($mapper)->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $configuration
|
||||||
|
* @param array<int|string, int|string> $statusReplacements
|
||||||
|
* @return list<string> areas which retained referenced local values
|
||||||
|
*/
|
||||||
|
public function apply(Project $targetProject, array $configuration, array $statusReplacements = []): array
|
||||||
|
{
|
||||||
|
$configuration = $this->normalizeConfiguration($configuration);
|
||||||
|
|
||||||
|
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements): array {
|
||||||
|
$blockedAreas = [];
|
||||||
|
|
||||||
|
if (isset($configuration[self::COPY_PROJECT_SETTINGS])) {
|
||||||
|
$targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($configuration[self::COPY_BOARD_COLUMNS])) {
|
||||||
|
$blocked = $this->syncModels(
|
||||||
|
$targetProject->boardColumns()->get(),
|
||||||
|
$configuration[self::COPY_BOARD_COLUMNS],
|
||||||
|
fn (array $item) => $targetProject->boardColumns()->create($item),
|
||||||
|
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
||||||
|
static fn (Model $column): bool => ! $column->ticketStatuses()->exists(),
|
||||||
|
);
|
||||||
|
if ($blocked) {
|
||||||
|
$blockedAreas[] = self::COPY_BOARD_COLUMNS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($configuration[self::COPY_TICKET_STATUSES])) {
|
||||||
|
$columns = $targetProject->boardColumns()->get()->values();
|
||||||
|
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns): array {
|
||||||
|
$columnIndex = $item['board_column_index'] ?? null;
|
||||||
|
$item['board_column_id'] = $columnIndex === null ? null : $columns->get($columnIndex)?->id;
|
||||||
|
unset($item['board_column_index']);
|
||||||
|
|
||||||
|
return $item;
|
||||||
|
})->all();
|
||||||
|
$blocked = $this->syncModels(
|
||||||
|
$targetProject->ticketStatuses()->get(),
|
||||||
|
$items,
|
||||||
|
fn (array $item) => $targetProject->ticketStatuses()->create($item),
|
||||||
|
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
||||||
|
function (Model $status) use ($statusReplacements): bool {
|
||||||
|
if (! $status->tickets()->exists()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$replacementId = $statusReplacements[$status->id] ?? null;
|
||||||
|
if ($replacementId === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$status->tickets()->update(['ticket_status_id' => $replacementId]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if ($blocked) {
|
||||||
|
$blockedAreas[] = self::COPY_TICKET_STATUSES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($configuration[self::COPY_STATUS_TRANSITIONS])) {
|
||||||
|
$statuses = $targetProject->ticketStatuses()->get()->values();
|
||||||
|
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $index => $targets) {
|
||||||
|
$status = $statuses->get($index);
|
||||||
|
if ($status !== null) {
|
||||||
|
$status->allowedTargetStatuses()->sync(collect($targets)->map(fn (int $target): ?int => $statuses->get($target)?->id)->filter()->all());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$relations = [
|
||||||
|
self::COPY_TICKET_TYPES => ['ticketTypes', fn (Model $item): bool => ! $item->tickets()->exists()],
|
||||||
|
self::COPY_TICKET_PRIORITIES => ['ticketPriorities', fn (Model $item): bool => ! $item->tickets()->exists()],
|
||||||
|
self::COPY_TICKET_FIELDS => ['ticketFieldDefinitions', fn (Model $item): bool => ! $item->ticketFieldValues()->exists()],
|
||||||
|
self::COPY_TICKET_LINK_TYPES => ['ticketLinkTypes', fn (Model $item): bool => ! $item->ticketLinks()->exists()],
|
||||||
|
self::COPY_REPOSITORY_CONNECTIONS => ['repositoryConnections', null],
|
||||||
|
self::COPY_NOTIFICATION_ACTIONS => ['notificationActions', null],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($relations as $area => [$relation, $canDelete]) {
|
||||||
|
if (! isset($configuration[$area])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$blocked = $this->syncModels(
|
||||||
|
$targetProject->{$relation}()->get(),
|
||||||
|
$configuration[$area],
|
||||||
|
fn (array $item) => $targetProject->{$relation}()->create($item),
|
||||||
|
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
||||||
|
$canDelete,
|
||||||
|
);
|
||||||
|
if ($blocked) {
|
||||||
|
$blockedAreas[] = $area;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($targetProject->configurationRelations() as $relation) {
|
||||||
|
$targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($blockedAreas));
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach ($blockedAreas as $area) {
|
||||||
|
$this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $blockedAreas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $copyAreas */
|
||||||
|
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
|
||||||
|
{
|
||||||
|
$this->apply($targetProject, $this->export($sourceProject, $copyAreas));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param EloquentCollection<int, Model> $models
|
||||||
|
* @param array<int, array<string, mixed>> $items
|
||||||
|
*/
|
||||||
|
private function syncModels(EloquentCollection $models, array $items, callable $create, callable $update, ?callable $canDelete = null): bool
|
||||||
|
{
|
||||||
|
foreach ($items as $index => $item) {
|
||||||
|
$model = $models->get($index);
|
||||||
|
$model === null ? $create($item) : $update($model, $item);
|
||||||
|
}
|
||||||
|
|
||||||
|
$blocked = false;
|
||||||
|
foreach ($models->slice(count($items)) as $model) {
|
||||||
|
if ($canDelete !== null && ! $canDelete($model)) {
|
||||||
|
$blocked = true;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$model->deleteQuietly();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $blocked;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
|
||||||
|
class ProjectTemplateOverrideManager
|
||||||
|
{
|
||||||
|
private static int $suppressionDepth = 0;
|
||||||
|
|
||||||
|
public function mark(Project $project, string $area): void
|
||||||
|
{
|
||||||
|
if (self::$suppressionDepth > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$project->loadMissing('projectTemplate');
|
||||||
|
|
||||||
|
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$overrides = array_values(array_unique([
|
||||||
|
...($project->template_overrides ?? []),
|
||||||
|
$area,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $areas
|
||||||
|
*/
|
||||||
|
public function clear(Project $project, array $areas): void
|
||||||
|
{
|
||||||
|
$remaining = array_values(array_diff($project->template_overrides ?? [], $areas));
|
||||||
|
$project->forceFill(['template_overrides' => $remaining])->saveQuietly();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withoutRecording(callable $callback): mixed
|
||||||
|
{
|
||||||
|
self::$suppressionDepth++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $callback();
|
||||||
|
} finally {
|
||||||
|
self::$suppressionDepth--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isRecording(): bool
|
||||||
|
{
|
||||||
|
return self::$suppressionDepth === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
|
||||||
|
class RefreshProjectBaseTemplate
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(Project $project, string $area): void
|
||||||
|
{
|
||||||
|
$project->loadMissing('projectTemplate');
|
||||||
|
|
||||||
|
if ($project->projectTemplate === null || $project->projectTemplate->is_published) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||||
|
$project->projectTemplate->configuration_snapshot ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
$project->projectTemplate->forceFill([
|
||||||
|
'configuration_snapshot' => array_replace(
|
||||||
|
$snapshot,
|
||||||
|
$this->duplicateProjectConfiguration->export($project->fresh(), [$area]),
|
||||||
|
),
|
||||||
|
])->saveQuietly();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
|
||||||
|
class ResolveProjectTemplateState
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string>|null $areas
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function handle(Project $project, ?array $areas = null): ?array
|
||||||
|
{
|
||||||
|
$project->loadMissing('projectTemplate');
|
||||||
|
|
||||||
|
/** @var ProjectTemplate|null $projectTemplate */
|
||||||
|
$projectTemplate = $project->projectTemplate;
|
||||||
|
|
||||||
|
if ($projectTemplate === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedAreas = $areas === null
|
||||||
|
? DuplicateProjectConfiguration::copyableAreas()
|
||||||
|
: $this->duplicateProjectConfiguration->normalizeCopyAreas($areas);
|
||||||
|
|
||||||
|
$normalizedTemplateConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||||
|
$projectTemplate->configuration_snapshot ?? [],
|
||||||
|
);
|
||||||
|
$templateConfiguration = collect($normalizedTemplateConfiguration)
|
||||||
|
->only($selectedAreas)
|
||||||
|
->all();
|
||||||
|
$currentConfiguration = $this->duplicateProjectConfiguration->export($project, $selectedAreas);
|
||||||
|
$persistedOverrides = $projectTemplate->is_published
|
||||||
|
? array_values(array_intersect($project->template_overrides ?? [], $selectedAreas))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$overriddenAreas = [];
|
||||||
|
$inheritedAreas = [];
|
||||||
|
|
||||||
|
foreach ($selectedAreas as $area) {
|
||||||
|
$label = $this->labelForArea($area);
|
||||||
|
$hasTemplateValue = array_key_exists($area, $templateConfiguration);
|
||||||
|
|
||||||
|
if (! $hasTemplateValue) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($area, $persistedOverrides, true)) {
|
||||||
|
$inheritedAreas[] = [
|
||||||
|
'label' => $label,
|
||||||
|
'value' => $area,
|
||||||
|
];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$overriddenAreas[] = [
|
||||||
|
'label' => $label,
|
||||||
|
'value' => $area,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$overriddenAreaValues = array_flip(array_column($overriddenAreas, 'value'));
|
||||||
|
$inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value'));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, $overriddenAreaValues)),
|
||||||
|
'inheritedAreas' => $inheritedAreas,
|
||||||
|
'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues),
|
||||||
|
'inheritsFromTemplate' => $projectTemplate->is_published,
|
||||||
|
'overriddenAreas' => $overriddenAreas,
|
||||||
|
'overriddenConfiguration' => array_intersect_key($currentConfiguration, $overriddenAreaValues),
|
||||||
|
'resetRequirements' => $this->resetRequirements($project, $templateConfiguration, $persistedOverrides),
|
||||||
|
'templateConfiguration' => $templateConfiguration,
|
||||||
|
'templateId' => $projectTemplate->id,
|
||||||
|
'templateName' => $projectTemplate->name,
|
||||||
|
'templateSlug' => $projectTemplate->slug,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $templateConfiguration
|
||||||
|
* @param list<string> $persistedOverrides
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function resetRequirements(Project $project, array $templateConfiguration, array $persistedOverrides): array
|
||||||
|
{
|
||||||
|
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $persistedOverrides, true)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [];
|
||||||
|
if (! is_array($templateStatuses)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
|
||||||
|
$replacementOptions = $statuses->take(count($templateStatuses))->map(
|
||||||
|
static fn ($status): array => ['id' => $status->id, 'name' => $status->name],
|
||||||
|
)->all();
|
||||||
|
$removedStatuses = $statuses->slice(count($templateStatuses))
|
||||||
|
->filter(static fn ($status): bool => $status->tickets_count > 0)
|
||||||
|
->map(static fn ($status): array => [
|
||||||
|
'id' => $status->id,
|
||||||
|
'name' => $status->name,
|
||||||
|
'ticketCount' => $status->tickets_count,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return $removedStatuses === [] ? [] : [
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [
|
||||||
|
'removedStatuses' => $removedStatuses,
|
||||||
|
'replacementOptions' => $replacementOptions,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function labelForArea(string $area): string
|
||||||
|
{
|
||||||
|
foreach (DuplicateProjectConfiguration::copyAreaOptions() as $option) {
|
||||||
|
if ($option['value'] === $area) {
|
||||||
|
return $option['label'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $area;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class UpdateProjectTemplate
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||||
|
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{name: string, copy_areas?: array<int, string>} $attributes
|
||||||
|
*/
|
||||||
|
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
|
||||||
|
{
|
||||||
|
$projectTemplate->loadMissing('sourceProject');
|
||||||
|
|
||||||
|
$sourceProject = $projectTemplate->sourceProject;
|
||||||
|
abort_if($sourceProject === null, 422, 'This template cannot be edited because the source project is missing.');
|
||||||
|
|
||||||
|
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
|
||||||
|
$normalizedCopyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas);
|
||||||
|
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||||
|
$projectTemplate->configuration_snapshot ?? [],
|
||||||
|
);
|
||||||
|
$nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas);
|
||||||
|
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
|
||||||
|
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void {
|
||||||
|
ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
|
||||||
|
|
||||||
|
$projectTemplate->forceFill([
|
||||||
|
'name' => $attributes['name'],
|
||||||
|
'copy_areas' => $normalizedCopyAreas,
|
||||||
|
'configuration_snapshot' => $nextConfiguration,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
Project::query()
|
||||||
|
->where('project_template_id', $projectTemplate->id)
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById(100, function ($linkedProjects) use ($affectedAreas, $nextConfiguration, $previousConfiguration): void {
|
||||||
|
foreach ($linkedProjects as $linkedProject) {
|
||||||
|
$linkedProject = Project::query()->whereKey($linkedProject->id)->lockForUpdate()->firstOrFail();
|
||||||
|
$currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject, $affectedAreas);
|
||||||
|
$overrides = $linkedProject->template_overrides ?? [];
|
||||||
|
$replacementConfiguration = [];
|
||||||
|
|
||||||
|
foreach ($affectedAreas as $area) {
|
||||||
|
if (in_array($area, $overrides, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! array_key_exists($area, $nextConfiguration)) {
|
||||||
|
$this->projectTemplateOverrideManager->mark($linkedProject, $area);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! array_key_exists($area, $previousConfiguration)
|
||||||
|
&& array_key_exists($area, $currentConfiguration)
|
||||||
|
&& $currentConfiguration[$area] !== []) {
|
||||||
|
$this->projectTemplateOverrideManager->mark($linkedProject, $area);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$replacementConfiguration[$area] = $nextConfiguration[$area];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($replacementConfiguration !== []) {
|
||||||
|
$this->duplicateProjectConfiguration->apply($linkedProject, $replacementConfiguration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class UpdateProjectTemplateLink
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||||
|
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function switch(Project $project, ProjectTemplate $template): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($project, $template): void {
|
||||||
|
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||||
|
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||||
|
$template->configuration_snapshot ?? [],
|
||||||
|
);
|
||||||
|
$localAreas = array_values(array_diff(
|
||||||
|
DuplicateProjectConfiguration::copyableAreas(),
|
||||||
|
array_keys($configuration),
|
||||||
|
));
|
||||||
|
|
||||||
|
$project->forceFill([
|
||||||
|
'project_template_id' => $template->id,
|
||||||
|
'template_overrides' => $localAreas,
|
||||||
|
])->saveQuietly();
|
||||||
|
|
||||||
|
$blockedAreas = $this->duplicateProjectConfiguration->apply($project, $configuration);
|
||||||
|
$this->projectTemplateOverrideManager->clear(
|
||||||
|
$project->fresh(),
|
||||||
|
array_values(array_diff(array_keys($configuration), $blockedAreas)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $areas
|
||||||
|
* @param array<int|string, int|string> $statusReplacements
|
||||||
|
*/
|
||||||
|
public function reset(Project $project, array $areas, array $statusReplacements = []): void
|
||||||
|
{
|
||||||
|
$template = $project->projectTemplate;
|
||||||
|
|
||||||
|
if ($template === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($project, $template, $areas, $statusReplacements): void {
|
||||||
|
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||||
|
$configuration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||||
|
$template->configuration_snapshot ?? [],
|
||||||
|
))->only($areas)->all();
|
||||||
|
$blockedAreas = $this->duplicateProjectConfiguration->apply(
|
||||||
|
$project,
|
||||||
|
$configuration,
|
||||||
|
$statusReplacements,
|
||||||
|
);
|
||||||
|
$this->projectTemplateOverrideManager->clear(
|
||||||
|
$project->fresh(),
|
||||||
|
array_values(array_diff(array_keys($configuration), $blockedAreas)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ namespace App\Actions\Repositories;
|
|||||||
|
|
||||||
use App\Actions\Git\Data\GitRepository;
|
use App\Actions\Git\Data\GitRepository;
|
||||||
use App\Actions\Git\GitProviderManager;
|
use App\Actions\Git\GitProviderManager;
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Actions\Projects\ResolveProjectTemplateState;
|
||||||
use App\Http\Resources\AvailableGitRepositorySourceResource;
|
use App\Http\Resources\AvailableGitRepositorySourceResource;
|
||||||
use App\Http\Resources\ProjectResource;
|
use App\Http\Resources\ProjectResource;
|
||||||
use App\Http\Resources\RepositoryConnectionResource;
|
use App\Http\Resources\RepositoryConnectionResource;
|
||||||
@@ -18,6 +20,7 @@ class BuildProjectRepositorySettingsPayload
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly GitProviderManager $gitProviderManager,
|
private readonly GitProviderManager $gitProviderManager,
|
||||||
|
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,6 +44,9 @@ class BuildProjectRepositorySettingsPayload
|
|||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
'repositoryConnections' => RepositoryConnectionResource::collection($repositoryConnections)->resolve(),
|
'repositoryConnections' => RepositoryConnectionResource::collection($repositoryConnections)->resolve(),
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
|
'templateState' => $this->resolveProjectTemplateState->handle($project, [
|
||||||
|
DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS,
|
||||||
|
]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class CreateProjectTicket
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly BuildTicketCreatedPayload $buildTicketCreatedPayload,
|
private readonly BuildTicketCreatedPayload $buildTicketCreatedPayload,
|
||||||
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
|
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
|
||||||
|
private readonly SyncProjectTicketFieldValues $syncProjectTicketFieldValues,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,7 +30,15 @@ class CreateProjectTicket
|
|||||||
* text: string
|
* text: string
|
||||||
* }>,
|
* }>,
|
||||||
* assignee_user_id?: int|null,
|
* assignee_user_id?: int|null,
|
||||||
|
* author_user_id?: int|null,
|
||||||
|
* blocked_comment?: string|null,
|
||||||
* description: string|null,
|
* description: string|null,
|
||||||
|
* field_values?: array<int, array{
|
||||||
|
* ticket_field_definition_id: int,
|
||||||
|
* value_number?: float|int|string|null,
|
||||||
|
* value_user_id?: int|string|null
|
||||||
|
* }>,
|
||||||
|
* is_blocked?: bool,
|
||||||
* parent_ticket_id?: int|null,
|
* parent_ticket_id?: int|null,
|
||||||
* story_points?: int|null,
|
* story_points?: int|null,
|
||||||
* ticket_priority_id: int,
|
* ticket_priority_id: int,
|
||||||
@@ -47,14 +56,17 @@ class CreateProjectTicket
|
|||||||
$nextSequenceNumber = (int) Ticket::query()
|
$nextSequenceNumber = (int) Ticket::query()
|
||||||
->where('project_id', $project->id)
|
->where('project_id', $project->id)
|
||||||
->max('sequence_number') + 1;
|
->max('sequence_number') + 1;
|
||||||
$ticketStatus = TicketStatus::query()
|
$ticketStatusQuery = TicketStatus::query()->where('project_id', $project->id);
|
||||||
->where('project_id', $project->id)
|
|
||||||
->findOrFail(Arr::get($attributes, 'ticket_status_id'));
|
$ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id'));
|
||||||
|
$isBlocked = (bool) Arr::get($attributes, 'is_blocked', false);
|
||||||
|
|
||||||
$ticket = Ticket::query()->create([
|
$ticket = Ticket::query()->create([
|
||||||
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
|
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
|
||||||
'author_user_id' => $actor?->id,
|
'author_user_id' => Arr::get($attributes, 'author_user_id', $actor?->id),
|
||||||
|
'blocked_comment' => $isBlocked ? Arr::get($attributes, 'blocked_comment') : null,
|
||||||
'description' => Arr::get($attributes, 'description'),
|
'description' => Arr::get($attributes, 'description'),
|
||||||
|
'is_blocked' => $isBlocked,
|
||||||
'parent_ticket_id' => Arr::get($attributes, 'parent_ticket_id'),
|
'parent_ticket_id' => Arr::get($attributes, 'parent_ticket_id'),
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
'sequence_number' => $nextSequenceNumber,
|
'sequence_number' => $nextSequenceNumber,
|
||||||
@@ -68,6 +80,17 @@ class CreateProjectTicket
|
|||||||
|
|
||||||
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $acceptanceCriteria);
|
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $acceptanceCriteria);
|
||||||
|
|
||||||
|
$fieldValues = Arr::get($attributes, 'field_values', []);
|
||||||
|
|
||||||
|
if ($fieldValues !== []) {
|
||||||
|
$this->syncProjectTicketFieldValues->handle(
|
||||||
|
$ticket,
|
||||||
|
$project->ticketFieldDefinitions()->get()->keyBy('id'),
|
||||||
|
collect(),
|
||||||
|
$fieldValues,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$occurredAt = CarbonImmutable::now();
|
$occurredAt = CarbonImmutable::now();
|
||||||
|
|
||||||
if ($actor !== null) {
|
if ($actor !== null) {
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
|
|||||||
->where('assignee_user_id', $assignee->id)
|
->where('assignee_user_id', $assignee->id)
|
||||||
->whereIn('project_id', $visibleProjectIds)
|
->whereIn('project_id', $visibleProjectIds)
|
||||||
->with([
|
->with([
|
||||||
|
'acceptanceCriteria',
|
||||||
'assignee',
|
'assignee',
|
||||||
'project',
|
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
'ticketType',
|
'ticketType',
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ class UpdateUserAvatar
|
|||||||
$color = $this->colorFor($user);
|
$color = $this->colorFor($user);
|
||||||
$path = sprintf('avatars/generated/%d-%s.svg', $user->id, (string) Str::uuid());
|
$path = sprintf('avatars/generated/%d-%s.svg', $user->id, (string) Str::uuid());
|
||||||
|
|
||||||
$stored = Storage::disk('public')->put(
|
$stored = Storage::disk($this->avatarDisk())->put(
|
||||||
$path,
|
$path,
|
||||||
$this->svg($this->initials($user->name), $color),
|
$this->svg($this->initials($user->name), $color),
|
||||||
|
['ContentType' => 'image/svg+xml'],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! $stored) {
|
if (! $stored) {
|
||||||
@@ -53,7 +54,7 @@ class UpdateUserAvatar
|
|||||||
public function upload(User $user, UploadedFile $avatar): User
|
public function upload(User $user, UploadedFile $avatar): User
|
||||||
{
|
{
|
||||||
$oldPath = $user->avatar_path;
|
$oldPath = $user->avatar_path;
|
||||||
$path = $avatar->store('avatars/uploads', 'public');
|
$path = $avatar->store('avatars/uploads', $this->avatarDisk());
|
||||||
|
|
||||||
if (! is_string($path)) {
|
if (! is_string($path)) {
|
||||||
throw new RuntimeException('Uploaded avatar could not be stored.');
|
throw new RuntimeException('Uploaded avatar could not be stored.');
|
||||||
@@ -90,7 +91,14 @@ class UpdateUserAvatar
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Storage::disk('public')->delete($path);
|
Storage::disk($this->avatarDisk())->delete($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function avatarDisk(): string
|
||||||
|
{
|
||||||
|
$disk = config('filesystems.avatar_disk', 'public');
|
||||||
|
|
||||||
|
return is_string($disk) && $disk !== '' ? $disk : 'public';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function initials(string $name): string
|
private function initials(string $name): string
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Models\User;
|
|||||||
use Illuminate\Console\Attributes\Description;
|
use Illuminate\Console\Attributes\Description;
|
||||||
use Illuminate\Console\Attributes\Signature;
|
use Illuminate\Console\Attributes\Signature;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
#[Signature('app:create-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')]
|
#[Signature('app:create-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')]
|
||||||
@@ -25,6 +26,9 @@ class CreateAdminUser extends Command
|
|||||||
*/
|
*/
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
|
DB::beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
$user = User::create([
|
$user = User::create([
|
||||||
'name' => $this->argument('name'),
|
'name' => $this->argument('name'),
|
||||||
'email' => $this->argument('email'),
|
'email' => $this->argument('email'),
|
||||||
@@ -38,7 +42,14 @@ class CreateAdminUser extends Command
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->info(sprintf('Admin User created with emil=\'%s\' and password=\'%s\'', $this->argument('email'), $this->argument('password')));
|
$this->info(sprintf('Admin User created with emil=\'%s\' and password=\'%s\'', $this->argument('email'), $this->argument('password')));
|
||||||
|
DB::commit();
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
|
} catch (\Exception $exception) {
|
||||||
|
$this->error('Admin User could not be created: ', $exception->getMessage());
|
||||||
|
DB::rollBack();
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
return Inertia::render('Dashboard', [
|
||||||
|
'assignedTicketsByProject' => $this->listAssignedTicketsByVisibleProject->handle($user, $user),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,16 +6,23 @@ use App\Actions\Permissions\PermissionService;
|
|||||||
use App\Actions\Projects\BuildProjectBacklogPayload;
|
use App\Actions\Projects\BuildProjectBacklogPayload;
|
||||||
use App\Actions\Projects\CreateProject;
|
use App\Actions\Projects\CreateProject;
|
||||||
use App\Actions\Projects\DeleteProject;
|
use App\Actions\Projects\DeleteProject;
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
use App\Actions\Projects\ListVisibleProjects;
|
use App\Actions\Projects\ListVisibleProjects;
|
||||||
|
use App\Actions\Projects\ResolveProjectTemplateState;
|
||||||
|
use App\Actions\Projects\UpdateProjectTemplateLink;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Http\Requests\DeleteProjectRequest;
|
use App\Http\Requests\DeleteProjectRequest;
|
||||||
|
use App\Http\Requests\ResetProjectTemplateRequest;
|
||||||
use App\Http\Requests\StoreProjectRequest;
|
use App\Http\Requests\StoreProjectRequest;
|
||||||
|
use App\Http\Requests\SwitchProjectTemplateRequest;
|
||||||
use App\Http\Requests\SyncProjectPermissionsRequest;
|
use App\Http\Requests\SyncProjectPermissionsRequest;
|
||||||
use App\Http\Resources\PermissionOptionResource;
|
use App\Http\Resources\PermissionOptionResource;
|
||||||
use App\Http\Resources\ProjectResource;
|
use App\Http\Resources\ProjectResource;
|
||||||
|
use App\Http\Resources\ProjectTemplateResource;
|
||||||
use App\Http\Resources\TeamResource;
|
use App\Http\Resources\TeamResource;
|
||||||
use App\Http\Resources\UserResource;
|
use App\Http\Resources\UserResource;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -30,8 +37,11 @@ class ProjectController extends Controller
|
|||||||
private readonly BuildProjectBacklogPayload $buildProjectBacklogPayload,
|
private readonly BuildProjectBacklogPayload $buildProjectBacklogPayload,
|
||||||
private readonly CreateProject $createProject,
|
private readonly CreateProject $createProject,
|
||||||
private readonly DeleteProject $deleteProject,
|
private readonly DeleteProject $deleteProject,
|
||||||
|
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||||
private readonly ListVisibleProjects $listVisibleProjects,
|
private readonly ListVisibleProjects $listVisibleProjects,
|
||||||
private readonly PermissionService $permissionService,
|
private readonly PermissionService $permissionService,
|
||||||
|
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||||
|
private readonly UpdateProjectTemplateLink $updateProjectTemplateLink,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
@@ -51,7 +61,23 @@ class ProjectController extends Controller
|
|||||||
403,
|
403,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Inertia::render('projects/Create');
|
$sourceTemplates = ProjectTemplate::query()
|
||||||
|
->published()
|
||||||
|
->with('sourceProject')
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
$selectedTemplateId = $request->integer('template');
|
||||||
|
|
||||||
|
return Inertia::render('projects/Create', [
|
||||||
|
'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(),
|
||||||
|
'sourceProjects' => ProjectResource::collection(
|
||||||
|
$this->listVisibleProjects->handle($request->user()),
|
||||||
|
)->resolve(),
|
||||||
|
'sourceTemplates' => ProjectTemplateResource::collection($sourceTemplates)->resolve(),
|
||||||
|
'selectedSourceTemplateId' => $sourceTemplates->contains('id', $selectedTemplateId)
|
||||||
|
? $selectedTemplateId
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(StoreProjectRequest $request): RedirectResponse
|
public function store(StoreProjectRequest $request): RedirectResponse
|
||||||
@@ -73,6 +99,7 @@ class ProjectController extends Controller
|
|||||||
return Inertia::render('projects/Show', [
|
return Inertia::render('projects/Show', [
|
||||||
'canManageAccess' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
'canManageAccess' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
|
'templateState' => $this->resolveProjectTemplateState->handle($project),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,10 +122,58 @@ class ProjectController extends Controller
|
|||||||
{
|
{
|
||||||
return Inertia::render('projects/settings/General', [
|
return Inertia::render('projects/settings/General', [
|
||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
|
'sourceTemplates' => ProjectTemplateResource::collection(
|
||||||
|
ProjectTemplate::query()
|
||||||
|
->published()
|
||||||
|
->with('sourceProject')
|
||||||
|
->orderBy('name')
|
||||||
|
->get(),
|
||||||
|
)->resolve(),
|
||||||
'status' => session('status'),
|
'status' => session('status'),
|
||||||
|
'templateState' => $this->resolveProjectTemplateState->handle($project),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function switchTemplate(SwitchProjectTemplateRequest $request, Project $project): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validated();
|
||||||
|
$template = ProjectTemplate::query()
|
||||||
|
->published()
|
||||||
|
->findOrFail((int) Arr::get($validated, 'source_template_id'));
|
||||||
|
|
||||||
|
$this->updateProjectTemplateLink->switch($project, $template);
|
||||||
|
|
||||||
|
return to_route('projects.settings.general', $project)
|
||||||
|
->with('status', "{$project->name} now uses the {$template->name} template.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetTemplate(ResetProjectTemplateRequest $request, Project $project): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validated();
|
||||||
|
|
||||||
|
$template = $project->projectTemplate;
|
||||||
|
|
||||||
|
abort_if($template === null, 422, 'This project has no linked template to reset.');
|
||||||
|
|
||||||
|
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||||
|
$template->configuration_snapshot ?? [],
|
||||||
|
);
|
||||||
|
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
|
||||||
|
$statusReplacements = Arr::get($validated, 'status_replacements', []);
|
||||||
|
|
||||||
|
$this->updateProjectTemplateLink->reset(
|
||||||
|
$project->load('projectTemplate'),
|
||||||
|
$areas,
|
||||||
|
$statusReplacements,
|
||||||
|
);
|
||||||
|
|
||||||
|
$status = Arr::has($validated, 'areas')
|
||||||
|
? "Selected settings were reset to the {$template->name} template."
|
||||||
|
: "{$project->name} was reset to the {$template->name} template baseline.";
|
||||||
|
|
||||||
|
return back()->with('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
public function access(Request $request, Project $project): Response
|
public function access(Request $request, Project $project): Response
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\Projects\CreateProjectTemplate;
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Actions\Projects\UpdateProjectTemplate;
|
||||||
|
use App\Http\Requests\StoreProjectTemplateRequest;
|
||||||
|
use App\Http\Requests\UpdateProjectTemplateRequest;
|
||||||
|
use App\Http\Resources\ProjectResource;
|
||||||
|
use App\Http\Resources\ProjectTemplateResource;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class ProjectTemplateController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CreateProjectTemplate $createProjectTemplate,
|
||||||
|
private readonly UpdateProjectTemplate $updateProjectTemplate,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('project-templates/Index', [
|
||||||
|
'status' => session('status'),
|
||||||
|
'templates' => ProjectTemplateResource::collection(
|
||||||
|
ProjectTemplate::query()
|
||||||
|
->published()
|
||||||
|
->with('sourceProject')
|
||||||
|
->withCount('linkedProjects')
|
||||||
|
->orderBy('name')
|
||||||
|
->get(),
|
||||||
|
)->resolve(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(ProjectTemplate $projectTemplate): Response
|
||||||
|
{
|
||||||
|
abort_if(! $projectTemplate->is_published, 404);
|
||||||
|
|
||||||
|
$projectTemplate->load('sourceProject')->loadCount('linkedProjects');
|
||||||
|
|
||||||
|
return Inertia::render('project-templates/Edit', [
|
||||||
|
'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(),
|
||||||
|
'template' => ProjectTemplateResource::make($projectTemplate)->resolve(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Project $project): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('project-templates/Create', [
|
||||||
|
'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(),
|
||||||
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreProjectTemplateRequest $request, Project $project): RedirectResponse
|
||||||
|
{
|
||||||
|
$template = $this->createProjectTemplate->handle($project, $request->validated());
|
||||||
|
|
||||||
|
return to_route('project-templates.index')
|
||||||
|
->with('status', "Template {$template->name} was created.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateProjectTemplateRequest $request, ProjectTemplate $projectTemplate): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_if(! $projectTemplate->is_published, 404);
|
||||||
|
|
||||||
|
$template = $this->updateProjectTemplate->handle($projectTemplate, $request->validated());
|
||||||
|
|
||||||
|
return to_route('project-templates.index')
|
||||||
|
->with('status', "Template {$template->name} was updated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(ProjectTemplate $projectTemplate): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_if(! $projectTemplate->is_published, 404);
|
||||||
|
|
||||||
|
$templateName = $projectTemplate->name;
|
||||||
|
$projectTemplate->delete();
|
||||||
|
|
||||||
|
return to_route('project-templates.index')
|
||||||
|
->with('status', "Template {$templateName} was deleted.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,11 @@ class HandleAppearance
|
|||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
View::share('appearance', $request->cookie('appearance') ?? 'system');
|
$appearance = $request->cookie('appearance');
|
||||||
|
$appearance = in_array($appearance, ['light', 'dark', 'system'], true) ? $appearance : 'system';
|
||||||
|
|
||||||
|
$request->attributes->set('appearance', $appearance);
|
||||||
|
View::share('appearance', $appearance);
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Http\Middleware;
|
|||||||
|
|
||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
|
use App\Models\Project;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Middleware;
|
use Inertia\Middleware;
|
||||||
|
|
||||||
@@ -46,6 +47,11 @@ class HandleInertiaRequests extends Middleware
|
|||||||
return [
|
return [
|
||||||
...parent::share($request),
|
...parent::share($request),
|
||||||
'name' => config('app.name'),
|
'name' => config('app.name'),
|
||||||
|
'appearance' => $request->attributes->get('appearance', 'system'),
|
||||||
|
'localization' => [
|
||||||
|
'locale' => app()->getLocale(),
|
||||||
|
'timeZone' => config('app.timezone'),
|
||||||
|
],
|
||||||
'auth' => [
|
'auth' => [
|
||||||
'permissions' => [
|
'permissions' => [
|
||||||
'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)),
|
'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)),
|
||||||
@@ -55,6 +61,36 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'user' => $user,
|
'user' => $user,
|
||||||
],
|
],
|
||||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||||
|
'navigation' => fn (): array => [
|
||||||
|
'project' => $this->projectNavigation($request),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{id: int, name: string, key: string, canManage: bool, canCreateTickets: bool}|null
|
||||||
|
*/
|
||||||
|
private function projectNavigation(Request $request): ?array
|
||||||
|
{
|
||||||
|
$project = $request->route('project');
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $project instanceof Project || $user === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissions = $this->permissionService->effectivePermissions($user, $project);
|
||||||
|
|
||||||
|
if (! in_array(Permission::ProjectRead, $permissions, true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $project->id,
|
||||||
|
'name' => $project->name,
|
||||||
|
'key' => $project->key,
|
||||||
|
'canManage' => in_array(Permission::ProjectAdmin, $permissions, true),
|
||||||
|
'canCreateTickets' => in_array(Permission::ProjectWrite, $permissions, true),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ class MoveProjectTicketRequest extends FormRequest
|
|||||||
'ticket_status_id' => [
|
'ticket_status_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_statuses', 'id')->where(
|
||||||
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ class DestroyProjectTicketTypeRequest extends FormRequest
|
|||||||
'replacement_ticket_type_id' => [
|
'replacement_ticket_type_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_types', 'id')->where(
|
||||||
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ class MoveProjectTicketRequest extends FormRequest
|
|||||||
'ticket_status_id' => [
|
'ticket_status_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_statuses', 'id')->where(
|
||||||
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])],
|
'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
class ResetProjectTemplateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
/** @var Project $project */
|
||||||
|
$project = $this->route('project');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'areas' => ['sometimes', 'array', 'min:1'],
|
||||||
|
'areas.*' => ['required', 'string', Rule::in(DuplicateProjectConfiguration::copyableAreas())],
|
||||||
|
'confirmation_key' => [
|
||||||
|
'required_without:areas',
|
||||||
|
'string',
|
||||||
|
Rule::in([$project->key]),
|
||||||
|
],
|
||||||
|
'status_replacements' => ['sometimes', 'array'],
|
||||||
|
'status_replacements.*' => [
|
||||||
|
'required',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, callable(Validator): void> */
|
||||||
|
public function after(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
function (Validator $validator): void {
|
||||||
|
if ($validator->errors()->isNotEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Project $project */
|
||||||
|
$project = $this->route('project');
|
||||||
|
$validated = $validator->validated();
|
||||||
|
$areas = array_values(Arr::get(
|
||||||
|
$validated,
|
||||||
|
'areas',
|
||||||
|
DuplicateProjectConfiguration::copyableAreas(),
|
||||||
|
));
|
||||||
|
|
||||||
|
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $areas, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$project->loadMissing('projectTemplate');
|
||||||
|
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration(
|
||||||
|
$project->projectTemplate?->configuration_snapshot ?? [],
|
||||||
|
);
|
||||||
|
$templateStatuses = Arr::get(
|
||||||
|
$configuration,
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
|
||||||
|
$allowedReplacementIds = $statuses->take(count($templateStatuses))->pluck('id');
|
||||||
|
$replacements = Arr::get($validated, 'status_replacements', []);
|
||||||
|
|
||||||
|
foreach ($statuses->slice(count($templateStatuses)) as $status) {
|
||||||
|
if ($status->tickets_count === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$replacementId = (int) Arr::get($replacements, (string) $status->id, 0);
|
||||||
|
if (! $allowedReplacementIds->contains($replacementId)) {
|
||||||
|
$validator->errors()->add(
|
||||||
|
"status_replacements.{$status->id}",
|
||||||
|
"Choose a replacement status for {$status->name}.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'confirmation_key.in' => 'Enter the exact project key to reset this project to its template baseline.',
|
||||||
|
'areas.*.in' => 'Choose a valid template area to reset.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Actions\Permissions\PermissionService;
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Enums\Permission;
|
||||||
|
use App\Models\Project;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@@ -20,12 +24,21 @@ class StoreProjectRequest extends FormRequest
|
|||||||
protected function prepareForValidation(): void
|
protected function prepareForValidation(): void
|
||||||
{
|
{
|
||||||
$key = Arr::get($this->all(), 'key');
|
$key = Arr::get($this->all(), 'key');
|
||||||
|
$creationMode = Arr::get($this->all(), 'creation_mode', 'scratch');
|
||||||
|
$copyAreas = Arr::get($this->all(), 'copy_areas', []);
|
||||||
|
|
||||||
if ($key !== null) {
|
if ($key !== null) {
|
||||||
$this->merge([
|
$this->merge([
|
||||||
'key' => strtoupper(trim((string) $key)),
|
'key' => strtoupper(trim((string) $key)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->merge([
|
||||||
|
'creation_mode' => is_string($creationMode) ? trim($creationMode) : 'scratch',
|
||||||
|
'copy_areas' => is_array($copyAreas)
|
||||||
|
? array_values($copyAreas)
|
||||||
|
: ($copyAreas === null || $copyAreas === '' ? [] : [(string) $copyAreas]),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +49,7 @@ class StoreProjectRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'creation_mode' => ['required', 'string', Rule::in(['scratch', 'duplicate', 'template'])],
|
||||||
'key' => [
|
'key' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
@@ -44,6 +58,51 @@ class StoreProjectRequest extends FormRequest
|
|||||||
Rule::unique('projects', 'key'),
|
Rule::unique('projects', 'key'),
|
||||||
],
|
],
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'source_project_id' => [
|
||||||
|
Rule::requiredIf(fn (): bool => $this->input('creation_mode') === 'duplicate'),
|
||||||
|
'nullable',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('projects', 'id'),
|
||||||
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$project = Project::query()->find($value);
|
||||||
|
|
||||||
|
if ($project === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissionService = app(PermissionService::class);
|
||||||
|
|
||||||
|
if (! $permissionService->allows($this->user(), Permission::ProjectRead, $project)) {
|
||||||
|
$fail('Select a project you can access as the duplication source.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'source_template_id' => [
|
||||||
|
Rule::requiredIf(fn (): bool => $this->input('creation_mode') === 'template'),
|
||||||
|
'nullable',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('project_templates', 'id')->where(fn ($query) => $query->where('is_published', true)),
|
||||||
|
],
|
||||||
|
'copy_areas' => ['nullable', 'array'],
|
||||||
|
'copy_areas.*' => [
|
||||||
|
'string',
|
||||||
|
Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas()),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'source_project_id.required' => 'Select a source project to duplicate from.',
|
||||||
|
'source_template_id.required' => 'Select a template to create the project from.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreProjectTemplateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('create-project') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if ($this->filled('slug')) {
|
||||||
|
$this->merge([
|
||||||
|
'slug' => Str::slug((string) $this->input('slug')),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')],
|
||||||
|
'copy_areas' => ['required', 'array', 'min:1'],
|
||||||
|
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class StoreProjectTicketBranchRequest extends FormRequest
|
class StoreProjectTicketBranchRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -22,9 +24,18 @@ class StoreProjectTicketBranchRequest extends FormRequest
|
|||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
/** @var Project $project */
|
||||||
|
$project = $this->route('project');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'branch_name' => ['required', 'string', 'max:255'],
|
'branch_name' => ['required', 'string', 'max:255'],
|
||||||
'repository_connection_id' => ['required', 'integer', 'exists:repository_connections,id'],
|
'repository_connection_id' => [
|
||||||
|
'required',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('repository_connections', 'id')->where(
|
||||||
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
|
),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,17 +66,23 @@ class StoreProjectTicketRequest extends FormRequest
|
|||||||
'ticket_priority_id' => [
|
'ticket_priority_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_priorities', 'id')->where(
|
||||||
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
'ticket_status_id' => [
|
'ticket_status_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_statuses', 'id')->where(
|
||||||
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
'ticket_type_id' => [
|
'ticket_type_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_types', 'id')->where(
|
||||||
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
'story_points' => ['nullable', 'integer', 'min:0'],
|
'story_points' => ['nullable', 'integer', 'min:0'],
|
||||||
'title' => ['required', 'string', 'max:255'],
|
'title' => ['required', 'string', 'max:255'],
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ class StoreTicketLinkRequest extends FormRequest
|
|||||||
'ticket_link_type_id' => [
|
'ticket_link_type_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_link_types', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_link_types', 'id')->where(
|
||||||
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ class StoreTicketStatusRequest extends FormRequest
|
|||||||
'board_column_id' => [
|
'board_column_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('board_columns', 'id')->where('project_id', $project->id),
|
Rule::exists('board_columns', 'id')->where(
|
||||||
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class SwitchProjectTemplateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
/** @var Project $project */
|
||||||
|
$project = $this->route('project');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'confirmation_key' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
Rule::in([$project->key]),
|
||||||
|
],
|
||||||
|
'source_template_id' => [
|
||||||
|
'required',
|
||||||
|
'integer',
|
||||||
|
Rule::exists(ProjectTemplate::class, 'id')->where('is_published', true),
|
||||||
|
Rule::notIn(array_filter([$project->project_template_id])),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'confirmation_key.in' => 'Enter the exact project key to confirm the template switch.',
|
||||||
|
'source_template_id.not_in' => 'Choose a different published template to switch this project.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateProjectTemplateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('create-project') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
/** @var ProjectTemplate|null $projectTemplate */
|
||||||
|
$projectTemplate = $this->route('projectTemplate');
|
||||||
|
$requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'copy_areas' => ['required', 'array', 'min:1'],
|
||||||
|
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||||
|
'confirm_linked_projects_update' => [
|
||||||
|
Rule::requiredIf($requiresImpactConfirmation),
|
||||||
|
'accepted',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'confirm_linked_projects_update.accepted' => 'Acknowledge the linked project warning before saving template changes.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,7 +68,9 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
'field_values.*.ticket_field_definition_id' => [
|
'field_values.*.ticket_field_definition_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_field_definitions', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_field_definitions', 'id')->where(
|
||||||
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
'field_values.*.value_number' => ['nullable', 'numeric'],
|
'field_values.*.value_number' => ['nullable', 'numeric'],
|
||||||
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||||
@@ -81,12 +83,16 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
'ticket_priority_id' => [
|
'ticket_priority_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_priorities', 'id')->where(
|
||||||
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
'ticket_type_id' => [
|
'ticket_type_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_types', 'id')->where(
|
||||||
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
'title' => ['required', 'string', 'max:255'],
|
'title' => ['required', 'string', 'max:255'],
|
||||||
'story_points' => ['nullable', 'integer', 'min:0'],
|
'story_points' => ['nullable', 'integer', 'min:0'],
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ class UpdateTicketStatusTransitionsRequest extends FormRequest
|
|||||||
'allowed_ticket_status_ids' => ['nullable', 'array'],
|
'allowed_ticket_status_ids' => ['nullable', 'array'],
|
||||||
'allowed_ticket_status_ids.*' => [
|
'allowed_ticket_status_ids.*' => [
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_statuses', 'id')->where(
|
||||||
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class ValidateProjectTicketBranchRequest extends FormRequest
|
class ValidateProjectTicketBranchRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -22,9 +24,18 @@ class ValidateProjectTicketBranchRequest extends FormRequest
|
|||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
/** @var Project $project */
|
||||||
|
$project = $this->route('project');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'branch_name' => ['required', 'string', 'max:255'],
|
'branch_name' => ['required', 'string', 'max:255'],
|
||||||
'repository_connection_id' => ['required', 'integer', 'exists:repository_connections,id'],
|
'repository_connection_id' => [
|
||||||
|
'required',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('repository_connections', 'id')->where(
|
||||||
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
|
),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Http\Resources;
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Actions\Projects\ResolveProjectTemplateState;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
class ProjectResource extends JsonResource
|
class ProjectResource extends JsonResource
|
||||||
{
|
{
|
||||||
@@ -14,10 +16,33 @@ class ProjectResource extends JsonResource
|
|||||||
*/
|
*/
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
|
$this->resource->loadMissing('projectTemplate');
|
||||||
|
|
||||||
|
$templateState = app(ResolveProjectTemplateState::class)->handle($this->resource);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'key' => $this->key,
|
'key' => $this->key,
|
||||||
|
'project_template_id' => $this->project_template_id,
|
||||||
|
'project_template' => $this->projectTemplate === null
|
||||||
|
? null
|
||||||
|
: ProjectTemplateResource::make($this->projectTemplate)->resolve(),
|
||||||
|
'template_state' => $templateState === null
|
||||||
|
? null
|
||||||
|
: [
|
||||||
|
'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []),
|
||||||
|
'inherited_areas' => Arr::get($templateState, 'inheritedAreas', []),
|
||||||
|
'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []),
|
||||||
|
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
|
||||||
|
'overridden_areas' => Arr::get($templateState, 'overriddenAreas', []),
|
||||||
|
'overridden_configuration' => Arr::get($templateState, 'overriddenConfiguration', []),
|
||||||
|
'reset_requirements' => Arr::get($templateState, 'resetRequirements', []),
|
||||||
|
'template_configuration' => Arr::get($templateState, 'templateConfiguration', []),
|
||||||
|
'template_id' => Arr::get($templateState, 'templateId'),
|
||||||
|
'template_name' => Arr::get($templateState, 'templateName'),
|
||||||
|
'template_slug' => Arr::get($templateState, 'templateSlug'),
|
||||||
|
],
|
||||||
'show_author' => $this->show_author,
|
'show_author' => $this->show_author,
|
||||||
'show_story_points' => $this->show_story_points,
|
'show_story_points' => $this->show_story_points,
|
||||||
'created_at' => $this->created_at?->toISOString(),
|
'created_at' => $this->created_at?->toISOString(),
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class ProjectTemplateResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
if ($this->resource instanceof ProjectTemplate) {
|
||||||
|
$configuration = app(DuplicateProjectConfiguration::class);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'project_id' => $this->project_id,
|
||||||
|
'source_project_id' => $this->source_project_id,
|
||||||
|
'source_project' => $this->relationLoaded('sourceProject') && $this->sourceProject !== null
|
||||||
|
? ProjectResource::make($this->sourceProject)->resolve()
|
||||||
|
: null,
|
||||||
|
'name' => $this->name,
|
||||||
|
'slug' => $this->slug,
|
||||||
|
'is_published' => $this->is_published,
|
||||||
|
'copy_areas' => $configuration->normalizeCopyAreas($this->copy_areas ?? []),
|
||||||
|
'configuration_snapshot' => $configuration->normalizeConfiguration(
|
||||||
|
$this->configuration_snapshot ?? [],
|
||||||
|
),
|
||||||
|
'linked_project_count' => $this->whenCounted('linkedProjects'),
|
||||||
|
'created_at' => $this->created_at?->toISOString(),
|
||||||
|
'updated_at' => $this->updated_at?->toISOString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => Arr::get($this->resource, 'id'),
|
||||||
|
'project_id' => Arr::get($this->resource, 'project_id'),
|
||||||
|
'source_project_id' => Arr::get($this->resource, 'source_project_id'),
|
||||||
|
'source_project' => Arr::get($this->resource, 'source_project'),
|
||||||
|
'name' => Arr::get($this->resource, 'name'),
|
||||||
|
'slug' => Arr::get($this->resource, 'slug'),
|
||||||
|
'is_published' => Arr::get($this->resource, 'is_published', true),
|
||||||
|
'copy_areas' => Arr::get($this->resource, 'copy_areas', []),
|
||||||
|
'configuration_snapshot' => Arr::get($this->resource, 'configuration_snapshot', []),
|
||||||
|
'linked_project_count' => Arr::get($this->resource, 'linked_project_count'),
|
||||||
|
'created_at' => Arr::get($this->resource, 'created_at'),
|
||||||
|
'updated_at' => Arr::get($this->resource, 'updated_at'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Mcp\Servers;
|
namespace App\Mcp\Servers;
|
||||||
|
|
||||||
|
use App\Mcp\Tools\CreateTicket;
|
||||||
use App\Mcp\Tools\GetTicket;
|
use App\Mcp\Tools\GetTicket;
|
||||||
use App\Mcp\Tools\ListMyAssignedTickets;
|
use App\Mcp\Tools\ListMyAssignedTickets;
|
||||||
use App\Mcp\Tools\ListTicketsByStatus;
|
use App\Mcp\Tools\ListTicketsByStatus;
|
||||||
|
use App\Mcp\Tools\ListTicketTypes;
|
||||||
use App\Mcp\Tools\MoveTicketStatus;
|
use App\Mcp\Tools\MoveTicketStatus;
|
||||||
use App\Mcp\Tools\UpdateTicketAssignee;
|
use App\Mcp\Tools\UpdateTicketAssignee;
|
||||||
use App\Mcp\Tools\UpdateTicketDescription;
|
use App\Mcp\Tools\UpdateTicketDescription;
|
||||||
@@ -15,7 +17,7 @@ use Laravel\Mcp\Server\Attributes\Version;
|
|||||||
|
|
||||||
#[Name('KanbanGit MCP')]
|
#[Name('KanbanGit MCP')]
|
||||||
#[Version('0.1.0')]
|
#[Version('0.1.0')]
|
||||||
#[Instructions('Use these tools to read and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially ticket_id and ticket_status_id.')]
|
#[Instructions('Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_ticket_types to discover the available ticket types in a project. Use create_ticket with parent_ticket_id to create a child ticket.')]
|
||||||
class KanbanGitServer extends Server
|
class KanbanGitServer extends Server
|
||||||
{
|
{
|
||||||
protected array $tools = [
|
protected array $tools = [
|
||||||
@@ -25,6 +27,8 @@ class KanbanGitServer extends Server
|
|||||||
UpdateTicketDescription::class,
|
UpdateTicketDescription::class,
|
||||||
UpdateTicketAssignee::class,
|
UpdateTicketAssignee::class,
|
||||||
MoveTicketStatus::class,
|
MoveTicketStatus::class,
|
||||||
|
CreateTicket::class,
|
||||||
|
ListTicketTypes::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
protected array $resources = [
|
protected array $resources = [
|
||||||
|
|||||||
@@ -120,6 +120,24 @@ class KanbanGitMcp
|
|||||||
return $project;
|
return $project;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*/
|
||||||
|
public function projectForWrite(User $user, int $projectId): Project
|
||||||
|
{
|
||||||
|
$project = Project::query()->find($projectId);
|
||||||
|
|
||||||
|
if ($project === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'project_id' => 'Project was not found.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->ensureCanWrite($user, $project);
|
||||||
|
|
||||||
|
return $project;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $validated
|
* @param array<string, mixed> $validated
|
||||||
* @return Collection<int, TicketStatus>|Response
|
* @return Collection<int, TicketStatus>|Response
|
||||||
@@ -189,10 +207,12 @@ class KanbanGitMcp
|
|||||||
public function ticketPayload(Ticket $ticket): array
|
public function ticketPayload(Ticket $ticket): array
|
||||||
{
|
{
|
||||||
$ticket->load([
|
$ticket->load([
|
||||||
|
'acceptanceCriteria',
|
||||||
'assignee',
|
'assignee',
|
||||||
'author',
|
'author',
|
||||||
'fieldValues.ticketFieldDefinition',
|
'fieldValues.ticketFieldDefinition',
|
||||||
'fieldValues.userValue',
|
'fieldValues.userValue',
|
||||||
|
'parentTicket',
|
||||||
'project',
|
'project',
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
|
use App\Actions\Permissions\PermissionService;
|
||||||
|
use App\Actions\Tickets\CreateProjectTicket;
|
||||||
|
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||||
|
use App\Enums\Permission;
|
||||||
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Mcp\Request;
|
||||||
|
use Laravel\Mcp\Response;
|
||||||
|
use Laravel\Mcp\ResponseFactory;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Description;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Name;
|
||||||
|
use Laravel\Mcp\Server\Tool;
|
||||||
|
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||||
|
|
||||||
|
#[Name('create_ticket')]
|
||||||
|
#[Description('Create a ticket with all editable ticket fields, including its type, initial status, priority, assignee, author, description, story points, acceptance criteria, blocked state, and custom field values. Set parent_ticket_id to create a child of an existing ticket in the same project whose type allows children. Requires project write permission; overriding the author requires project admin permission. Ticket IDs, keys, sequence numbers, timestamps, and worklog entries are generated automatically.')]
|
||||||
|
#[IsDestructive(false)]
|
||||||
|
class CreateTicket extends Tool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
|
private readonly CreateProjectTicket $createProjectTicket,
|
||||||
|
private readonly PermissionService $permissionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
|
{
|
||||||
|
$projectInput = $request->validate([
|
||||||
|
'project_id' => ['required', 'integer'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $this->kanbanGitMcp->user($request);
|
||||||
|
$project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id'));
|
||||||
|
$validated = $request->validate([
|
||||||
|
'title' => ['required', 'string', 'max:255'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)],
|
||||||
|
'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)],
|
||||||
|
'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)],
|
||||||
|
'parent_ticket_id' => ['nullable', 'integer', Rule::exists('tickets', 'id')->where('project_id', $project->id)],
|
||||||
|
'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||||
|
'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||||
|
'story_points' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'is_blocked' => ['sometimes', 'boolean'],
|
||||||
|
'blocked_comment' => ['nullable', 'string'],
|
||||||
|
'acceptance_criteria' => ['nullable', 'array', 'list'],
|
||||||
|
'acceptance_criteria.*' => ['array:text,checked,indent'],
|
||||||
|
'acceptance_criteria.*.text' => ['required', 'string'],
|
||||||
|
'acceptance_criteria.*.checked' => ['sometimes', 'boolean'],
|
||||||
|
'acceptance_criteria.*.indent' => ['sometimes', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL],
|
||||||
|
'field_values' => ['sometimes', 'array', 'list'],
|
||||||
|
'field_values.*' => ['array:ticket_field_definition_id,value_number,value_user_id'],
|
||||||
|
'field_values.*.ticket_field_definition_id' => [
|
||||||
|
'required', 'integer', 'distinct',
|
||||||
|
Rule::exists('ticket_field_definitions', 'id')->where('project_id', $project->id),
|
||||||
|
],
|
||||||
|
'field_values.*.value_number' => ['nullable', 'numeric'],
|
||||||
|
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->validateTicketConstraints($project, $user, $validated);
|
||||||
|
|
||||||
|
$ticket = $this->createProjectTicket->handle($project, $validated, $user);
|
||||||
|
|
||||||
|
return Response::structured([
|
||||||
|
'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()),
|
||||||
|
'meta' => [
|
||||||
|
'message' => "{$ticket->ticket_key} was created.",
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $validated
|
||||||
|
*/
|
||||||
|
private function validateTicketConstraints(Project $project, User $user, array $validated): void
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
$parentTicketId = Arr::get($validated, 'parent_ticket_id');
|
||||||
|
|
||||||
|
if ($parentTicketId !== null) {
|
||||||
|
$parentTicket = Ticket::query()
|
||||||
|
->where('project_id', $project->id)
|
||||||
|
->with('ticketType')
|
||||||
|
->find($parentTicketId);
|
||||||
|
|
||||||
|
if (! $parentTicket?->ticketType?->is_parent) {
|
||||||
|
$errors['parent_ticket_id'] = 'Only tickets with a parent-enabled type can be selected as a parent.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Arr::get($validated, 'is_blocked', false) && blank(Arr::get($validated, 'blocked_comment'))) {
|
||||||
|
$errors['blocked_comment'] = 'Blocked tickets require a blocker comment.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Arr::exists($validated, 'author_user_id')
|
||||||
|
&& (int) Arr::get($validated, 'author_user_id') !== $user->id
|
||||||
|
&& ! $this->permissionService->allows($user, Permission::ProjectAdmin, $project)
|
||||||
|
) {
|
||||||
|
$errors['author_user_id'] = 'Only project admins can change the author.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$fieldValues = Arr::get($validated, 'field_values', []);
|
||||||
|
|
||||||
|
if ($fieldValues !== []) {
|
||||||
|
$fieldDefinitions = $project->ticketFieldDefinitions()->get()->keyBy('id');
|
||||||
|
|
||||||
|
foreach ($fieldValues as $index => $fieldValue) {
|
||||||
|
$fieldDefinition = $fieldDefinitions->get((int) Arr::get($fieldValue, 'ticket_field_definition_id'));
|
||||||
|
$incompatibleValue = $fieldDefinition->field_type === 'user' ? 'value_number' : 'value_user_id';
|
||||||
|
|
||||||
|
if (Arr::get($fieldValue, $incompatibleValue) !== null) {
|
||||||
|
$errors["field_values.{$index}.{$incompatibleValue}"] = 'The value must match the custom field type.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($errors !== []) {
|
||||||
|
throw ValidationException::withMessages($errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, Type>
|
||||||
|
*/
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'project_id' => $schema->integer()->description('Project ID.')->required(),
|
||||||
|
'title' => $schema->string()->max(255)->description('Ticket title.')->required(),
|
||||||
|
'description' => $schema->string()->nullable()->description('Ticket description.'),
|
||||||
|
'ticket_type_id' => $schema->integer()->description('Ticket type ID belonging to the project. Explicitly selects the type, including types that allow child tickets.')->required(),
|
||||||
|
'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(),
|
||||||
|
'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(),
|
||||||
|
'parent_ticket_id' => $schema->integer()->nullable()->description('Existing parent ticket ID in the same project. Its type must allow children. Omit or pass null for a top-level ticket.'),
|
||||||
|
'assignee_user_id' => $schema->integer()->nullable()->description('User ID to assign. Omit or pass null to leave unassigned.'),
|
||||||
|
'author_user_id' => $schema->integer()->nullable()->description('Defaults to the application key owner when omitted. Only project admins may choose another author or pass null for no author.'),
|
||||||
|
'story_points' => $schema->integer()->min(0)->nullable()->description('Non-negative story point estimate.'),
|
||||||
|
'is_blocked' => $schema->boolean()->default(false)->description('Whether the ticket is blocked. Requires a nonblank blocked_comment when true.'),
|
||||||
|
'blocked_comment' => $schema->string()->nullable()->description('Blocker reason. Stored only when is_blocked is true.'),
|
||||||
|
'acceptance_criteria' => $schema->array()->nullable()
|
||||||
|
->items($schema->object([
|
||||||
|
'text' => $schema->string()->description('Checklist item text.')->required(),
|
||||||
|
'checked' => $schema->boolean()->default(false),
|
||||||
|
'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0),
|
||||||
|
])->withoutAdditionalProperties())
|
||||||
|
->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items.'),
|
||||||
|
'field_values' => $schema->array()
|
||||||
|
->items($schema->object([
|
||||||
|
'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(),
|
||||||
|
'value_number' => $schema->number()->nullable()->description('Value for a numeric field.'),
|
||||||
|
'value_user_id' => $schema->integer()->nullable()->description('User ID for a user field.'),
|
||||||
|
])->withoutAdditionalProperties())
|
||||||
|
->description('Custom field values. Supply the value matching each definition type; omit or pass null for an empty value.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
|
use App\Http\Resources\ProjectResource;
|
||||||
|
use App\Http\Resources\TicketTypeResource;
|
||||||
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Laravel\Mcp\Request;
|
||||||
|
use Laravel\Mcp\Response;
|
||||||
|
use Laravel\Mcp\ResponseFactory;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Description;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Name;
|
||||||
|
use Laravel\Mcp\Server\Tool;
|
||||||
|
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||||
|
|
||||||
|
#[Name('list_ticket_types')]
|
||||||
|
#[Description('List all ticket types in a visible project in their configured order, including IDs, names, and is_parent. Use a returned ID as ticket_type_id when creating a ticket. Types with is_parent=true allow tickets of that type to have child tickets; set parent_ticket_id on create_ticket to attach a child to an existing parent ticket.')]
|
||||||
|
#[IsReadOnly]
|
||||||
|
class ListTicketTypes extends Tool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'project_id' => ['required', 'integer'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $this->kanbanGitMcp->user($request);
|
||||||
|
$project = $this->kanbanGitMcp->projectForRead($user, (int) Arr::get($validated, 'project_id'));
|
||||||
|
|
||||||
|
return Response::structured([
|
||||||
|
'data' => [
|
||||||
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
|
'ticket_types' => TicketTypeResource::collection($project->ticketTypes()->get())->resolve(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, Type>
|
||||||
|
*/
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'project_id' => $schema->integer()
|
||||||
|
->description('Project ID. Requires permission to view this project.')
|
||||||
|
->required(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\BoardColumnFactory;
|
use Database\Factories\BoardColumnFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -10,12 +11,21 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
|
|
||||||
#[Fillable(['name', 'position', 'project_id'])]
|
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
||||||
class BoardColumn extends Model
|
class BoardColumn extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<BoardColumnFactory> */
|
/** @use HasFactory<BoardColumnFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $boardColumn): void {
|
||||||
|
$boardColumn->syncProjectTemplateOwnership();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function project(): BelongsTo
|
public function project(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Project::class);
|
return $this->belongsTo(Project::class);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Concerns;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
trait SyncsProjectTemplateOwnership
|
||||||
|
{
|
||||||
|
protected function syncProjectTemplateOwnership(): void
|
||||||
|
{
|
||||||
|
$projectTemplateId = $this->getAttribute('project_template_id');
|
||||||
|
$projectId = $this->getAttribute('project_id');
|
||||||
|
|
||||||
|
if ($projectTemplateId === null && $projectId !== null) {
|
||||||
|
$projectTemplateId = Project::query()
|
||||||
|
->whereKey($projectId)
|
||||||
|
->value('project_template_id');
|
||||||
|
|
||||||
|
if ($projectTemplateId !== null) {
|
||||||
|
$this->setAttribute('project_template_id', $projectTemplateId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($projectId === null && $projectTemplateId !== null) {
|
||||||
|
$projectId = ProjectTemplate::query()
|
||||||
|
->whereKey($projectTemplateId)
|
||||||
|
->value('project_id');
|
||||||
|
|
||||||
|
if ($projectId !== null) {
|
||||||
|
$this->setAttribute('project_id', $projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function projectTemplate(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProjectTemplate::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
|||||||
|
|
||||||
use App\Enums\NotificationChannel;
|
use App\Enums\NotificationChannel;
|
||||||
use App\Enums\NotificationEventType;
|
use App\Enums\NotificationEventType;
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\NotificationActionFactory;
|
use Database\Factories\NotificationActionFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
@@ -23,6 +24,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
'message_template',
|
'message_template',
|
||||||
'name',
|
'name',
|
||||||
'project_id',
|
'project_id',
|
||||||
|
'project_template_id',
|
||||||
])]
|
])]
|
||||||
#[Hidden(['delivery_url'])]
|
#[Hidden(['delivery_url'])]
|
||||||
class NotificationAction extends Model
|
class NotificationAction extends Model
|
||||||
@@ -30,6 +32,8 @@ class NotificationAction extends Model
|
|||||||
/** @use HasFactory<NotificationActionFactory> */
|
/** @use HasFactory<NotificationActionFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
@@ -45,6 +49,13 @@ class NotificationAction extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $notificationAction): void {
|
||||||
|
$notificationAction->syncProjectTemplateOwnership();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function scopeEnabled(Builder $query): Builder
|
public function scopeEnabled(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('enabled', true);
|
return $query->where('enabled', true);
|
||||||
|
|||||||
+30
-1
@@ -7,11 +7,13 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
#[Fillable(['name', 'key', 'show_author', 'show_story_points'])]
|
#[Fillable(['name', 'key', 'project_template_id', 'show_author', 'show_story_points', 'template_overrides'])]
|
||||||
class Project extends Model
|
class Project extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<ProjectFactory> */
|
/** @use HasFactory<ProjectFactory> */
|
||||||
@@ -22,6 +24,7 @@ class Project extends Model
|
|||||||
return [
|
return [
|
||||||
'show_author' => 'boolean',
|
'show_author' => 'boolean',
|
||||||
'show_story_points' => 'boolean',
|
'show_story_points' => 'boolean',
|
||||||
|
'template_overrides' => 'array',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +47,17 @@ class Project extends Model
|
|||||||
return $this->morphMany(PermissionAssignment::class, 'scope');
|
return $this->morphMany(PermissionAssignment::class, 'scope');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function projectTemplate(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProjectTemplate::class, 'project_template_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function baseTemplate(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(ProjectTemplate::class)
|
||||||
|
->where('is_published', false);
|
||||||
|
}
|
||||||
|
|
||||||
public function boardColumns(): HasMany
|
public function boardColumns(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(BoardColumn::class)
|
return $this->hasMany(BoardColumn::class)
|
||||||
@@ -95,6 +109,21 @@ class Project extends Model
|
|||||||
->orderBy('id');
|
->orderBy('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function configurationRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'boardColumns',
|
||||||
|
'notificationActions',
|
||||||
|
'repositoryConnections',
|
||||||
|
'ticketFieldDefinitions',
|
||||||
|
'ticketLinkTypes',
|
||||||
|
'ticketPriorities',
|
||||||
|
'ticketStatuses',
|
||||||
|
'ticketTypes',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function tickets(): HasMany
|
public function tickets(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Ticket::class)
|
return $this->hasMany(Ticket::class)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\ProjectTemplateFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
#[Fillable(['project_id', 'source_project_id', 'name', 'slug', 'is_published', 'copy_areas', 'configuration_snapshot'])]
|
||||||
|
class ProjectTemplate extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<ProjectTemplateFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'copy_areas' => 'array',
|
||||||
|
'configuration_snapshot' => 'array',
|
||||||
|
'is_published' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopePublished(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('is_published', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function project(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sourceProject(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class, 'source_project_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function linkedProjects(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Project::class, 'project_template_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
|||||||
|
|
||||||
use App\Actions\Git\Data\GitRepository;
|
use App\Actions\Git\Data\GitRepository;
|
||||||
use App\Actions\Git\GitProviderKey;
|
use App\Actions\Git\GitProviderKey;
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\RepositoryConnectionFactory;
|
use Database\Factories\RepositoryConnectionFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
'base_url',
|
'base_url',
|
||||||
'owner_name',
|
'owner_name',
|
||||||
'project_id',
|
'project_id',
|
||||||
|
'project_template_id',
|
||||||
'provider',
|
'provider',
|
||||||
'provider_repository_id',
|
'provider_repository_id',
|
||||||
'repository_full_name',
|
'repository_full_name',
|
||||||
@@ -25,6 +27,8 @@ class RepositoryConnection extends Model
|
|||||||
/** @use HasFactory<RepositoryConnectionFactory> */
|
/** @use HasFactory<RepositoryConnectionFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
@@ -35,6 +39,13 @@ class RepositoryConnection extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $repositoryConnection): void {
|
||||||
|
$repositoryConnection->syncProjectTemplateOwnership();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function project(): BelongsTo
|
public function project(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Project::class);
|
return $this->belongsTo(Project::class);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\TicketFieldDefinitionFactory;
|
use Database\Factories\TicketFieldDefinitionFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable(['field_type', 'label', 'position', 'project_id'])]
|
#[Fillable(['field_type', 'label', 'position', 'project_id', 'project_template_id'])]
|
||||||
class TicketFieldDefinition extends Model
|
class TicketFieldDefinition extends Model
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@@ -23,6 +24,15 @@ class TicketFieldDefinition extends Model
|
|||||||
/** @use HasFactory<TicketFieldDefinitionFactory> */
|
/** @use HasFactory<TicketFieldDefinitionFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $ticketFieldDefinition): void {
|
||||||
|
$ticketFieldDefinition->syncProjectTemplateOwnership();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function project(): BelongsTo
|
public function project(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Project::class);
|
return $this->belongsTo(Project::class);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\TicketLinkTypeFactory;
|
use Database\Factories\TicketLinkTypeFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -9,12 +10,21 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable(['label', 'counterpart_label', 'position', 'project_id'])]
|
#[Fillable(['label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])]
|
||||||
class TicketLinkType extends Model
|
class TicketLinkType extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketLinkTypeFactory> */
|
/** @use HasFactory<TicketLinkTypeFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $ticketLinkType): void {
|
||||||
|
$ticketLinkType->syncProjectTemplateOwnership();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function counterpartLabel(): string
|
public function counterpartLabel(): string
|
||||||
{
|
{
|
||||||
return $this->counterpart_label ?: $this->label;
|
return $this->counterpart_label ?: $this->label;
|
||||||
|
|||||||
@@ -2,20 +2,36 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\TicketPriorityFactory;
|
use Database\Factories\TicketPriorityFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable(['name', 'position', 'project_id'])]
|
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
||||||
class TicketPriority extends Model
|
class TicketPriority extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketPriorityFactory> */
|
/** @use HasFactory<TicketPriorityFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $ticketPriority): void {
|
||||||
|
$ticketPriority->syncProjectTemplateOwnership();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function project(): BelongsTo
|
public function project(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Project::class);
|
return $this->belongsTo(Project::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function tickets(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Ticket::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\TicketStatusFactory;
|
use Database\Factories\TicketStatusFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -11,15 +12,24 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'slug'])]
|
#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])]
|
||||||
class TicketStatus extends Model
|
class TicketStatus extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketStatusFactory> */
|
/** @use HasFactory<TicketStatusFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
static::saving(function (self $ticketStatus): void {
|
static::saving(function (self $ticketStatus): void {
|
||||||
|
if ($ticketStatus->project_template_id === null && $ticketStatus->board_column_id !== null) {
|
||||||
|
$ticketStatus->project_template_id = BoardColumn::query()
|
||||||
|
->whereKey($ticketStatus->board_column_id)
|
||||||
|
->value('project_template_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketStatus->syncProjectTemplateOwnership();
|
||||||
$ticketStatus->slug = Str::slug($ticketStatus->name);
|
$ticketStatus->slug = Str::slug($ticketStatus->name);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\TicketTypeFactory;
|
use Database\Factories\TicketTypeFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -9,12 +10,21 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable(['name', 'position', 'project_id', 'is_parent'])]
|
#[Fillable(['name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
|
||||||
class TicketType extends Model
|
class TicketType extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketTypeFactory> */
|
/** @use HasFactory<TicketTypeFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $ticketType): void {
|
||||||
|
$ticketType->syncProjectTemplateOwnership();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
+19
-3
@@ -77,12 +77,28 @@ class User extends Authenticatable
|
|||||||
protected function avatar(): Attribute
|
protected function avatar(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::get(
|
return Attribute::get(
|
||||||
fn (): ?string => filled($this->avatar_path)
|
fn (): ?string => $this->avatarUrl(),
|
||||||
? Storage::disk('public')->url($this->avatar_path)
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function avatarUrl(): ?string
|
||||||
|
{
|
||||||
|
if (blank($this->avatar_path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$diskName = config('filesystems.avatar_disk', 'public');
|
||||||
|
$disk = Storage::disk(is_string($diskName) && $diskName !== '' ? $diskName : 'public');
|
||||||
|
|
||||||
|
if ((bool) config('filesystems.avatar_urls_temporary', false)) {
|
||||||
|
$expirationMinutes = max(1, (int) config('filesystems.avatar_url_expiration_minutes', 15));
|
||||||
|
|
||||||
|
return $disk->temporaryUrl($this->avatar_path, now()->addMinutes($expirationMinutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $disk->url($this->avatar_path);
|
||||||
|
}
|
||||||
|
|
||||||
public function permissionAssignments(): MorphMany
|
public function permissionAssignments(): MorphMany
|
||||||
{
|
{
|
||||||
return $this->morphMany(PermissionAssignment::class, 'assignee');
|
return $this->morphMany(PermissionAssignment::class, 'assignee');
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Observers;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||||
|
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||||
|
use App\Models\BoardColumn;
|
||||||
|
use App\Models\NotificationAction;
|
||||||
|
use App\Models\RepositoryConnection;
|
||||||
|
use App\Models\TicketFieldDefinition;
|
||||||
|
use App\Models\TicketLinkType;
|
||||||
|
use App\Models\TicketPriority;
|
||||||
|
use App\Models\TicketStatus;
|
||||||
|
use App\Models\TicketType;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class ProjectConfigurationObserver
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||||
|
private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function saved(Model $model): void
|
||||||
|
{
|
||||||
|
$this->markOverride($model);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleted(Model $model): void
|
||||||
|
{
|
||||||
|
$this->markOverride($model);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function markOverride(Model $model): void
|
||||||
|
{
|
||||||
|
if (! $this->projectTemplateOverrideManager->isRecording()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$project = $model->project()->first();
|
||||||
|
|
||||||
|
if ($project === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$area = match (true) {
|
||||||
|
$model instanceof BoardColumn => DuplicateProjectConfiguration::COPY_BOARD_COLUMNS,
|
||||||
|
$model instanceof TicketStatus => DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
|
||||||
|
$model instanceof TicketType => DuplicateProjectConfiguration::COPY_TICKET_TYPES,
|
||||||
|
$model instanceof TicketPriority => DuplicateProjectConfiguration::COPY_TICKET_PRIORITIES,
|
||||||
|
$model instanceof TicketFieldDefinition => DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
|
||||||
|
$model instanceof TicketLinkType => DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
|
||||||
|
$model instanceof RepositoryConnection => DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS,
|
||||||
|
$model instanceof NotificationAction => DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($area === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->refreshProjectBaseTemplate->handle($project, $area);
|
||||||
|
$this->projectTemplateOverrideManager->mark($project, $area);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,18 @@ use App\Actions\Notifications\TicketCreated;
|
|||||||
use App\Actions\Notifications\TicketStatusChanged;
|
use App\Actions\Notifications\TicketStatusChanged;
|
||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
|
use App\Models\BoardColumn;
|
||||||
|
use App\Models\NotificationAction;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\RepositoryConnection;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
|
use App\Models\TicketFieldDefinition;
|
||||||
|
use App\Models\TicketLinkType;
|
||||||
|
use App\Models\TicketPriority;
|
||||||
|
use App\Models\TicketStatus;
|
||||||
|
use App\Models\TicketType;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Observers\ProjectConfigurationObserver;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
use Illuminate\Support\Facades\Date;
|
use Illuminate\Support\Facades\Date;
|
||||||
@@ -36,6 +45,18 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
foreach ([
|
||||||
|
BoardColumn::class,
|
||||||
|
NotificationAction::class,
|
||||||
|
RepositoryConnection::class,
|
||||||
|
TicketFieldDefinition::class,
|
||||||
|
TicketLinkType::class,
|
||||||
|
TicketPriority::class,
|
||||||
|
TicketStatus::class,
|
||||||
|
TicketType::class,
|
||||||
|
] as $model) {
|
||||||
|
$model::observe(ProjectConfigurationObserver::class);
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->app->environment() !== 'local') {
|
if ($this->app->environment() !== 'local') {
|
||||||
URL::forceScheme('https');
|
URL::forceScheme('https');
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"skills": [
|
"skills": [
|
||||||
"fortify-development",
|
"fortify-development",
|
||||||
"laravel-best-practices",
|
"laravel-best-practices",
|
||||||
|
"mcp-development",
|
||||||
"wayfinder-development",
|
"wayfinder-development",
|
||||||
"inertia-vue-development",
|
"inertia-vue-development",
|
||||||
"tailwindcss-development"
|
"tailwindcss-development"
|
||||||
|
|||||||
+3
-1
@@ -17,6 +17,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
$middleware->trustProxies(at: '*');
|
||||||
|
|
||||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||||
|
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
@@ -31,5 +33,5 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
$exceptions->dontFlash(['api_token']);
|
||||||
})->create();
|
})->create();
|
||||||
|
|||||||
+9
-8
@@ -12,10 +12,11 @@
|
|||||||
"php": "^8.4",
|
"php": "^8.4",
|
||||||
"inertiajs/inertia-laravel": "^2.0",
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
"laravel/fortify": "^1.34",
|
"laravel/fortify": "^1.34",
|
||||||
"laravel/framework": "^13.0",
|
"laravel/framework": "^v13.26.1",
|
||||||
"laravel/mcp": "^0.6",
|
"laravel/mcp": "^0.6",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"laravel/wayfinder": "^0.1.14",
|
"laravel/wayfinder": "^0.1.14",
|
||||||
|
"league/flysystem-aws-s3-v3": "^3.0",
|
||||||
"scalar/laravel": "^0.2.1"
|
"scalar/laravel": "^0.2.1"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
@@ -46,15 +47,15 @@
|
|||||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||||
"@php artisan key:generate",
|
"@php artisan key:generate",
|
||||||
"@php artisan migrate --force",
|
"@php artisan migrate --force",
|
||||||
"npm install",
|
"pnpm install",
|
||||||
"npm run build"
|
"pnpm run build"
|
||||||
],
|
],
|
||||||
"dev": [
|
"dev": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"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"
|
"@php artisan dev"
|
||||||
],
|
],
|
||||||
"dev:ssr": [
|
"dev:ssr": [
|
||||||
"npm run build:ssr",
|
"pnpm run build:ssr",
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
|
||||||
],
|
],
|
||||||
@@ -66,9 +67,9 @@
|
|||||||
],
|
],
|
||||||
"ci:check": [
|
"ci:check": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"npm run lint:check",
|
"pnpm audit --audit-level=high",
|
||||||
"npm run format:check",
|
"pnpm run lint:check",
|
||||||
"npm run types:check",
|
"pnpm run types:check",
|
||||||
"@test"
|
"@test"
|
||||||
],
|
],
|
||||||
"test": [
|
"test": [
|
||||||
|
|||||||
Generated
+795
-336
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,12 @@ return [
|
|||||||
|
|
||||||
'default' => env('FILESYSTEM_DISK', 'local'),
|
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||||
|
|
||||||
|
'avatar_disk' => env('AVATAR_FILESYSTEM_DISK', env('FILESYSTEM_DISK', 'public')),
|
||||||
|
|
||||||
|
'avatar_urls_temporary' => env('AVATAR_URLS_TEMPORARY', false),
|
||||||
|
|
||||||
|
'avatar_url_expiration_minutes' => env('AVATAR_URL_EXPIRATION_MINUTES', 15),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Filesystem Disks
|
| Filesystem Disks
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class ProjectFactory extends Factory
|
|||||||
'key' => Str::upper(fake()->unique()->lexify('?????')),
|
'key' => Str::upper(fake()->unique()->lexify('?????')),
|
||||||
'show_author' => true,
|
'show_author' => true,
|
||||||
'show_story_points' => true,
|
'show_story_points' => true,
|
||||||
|
'template_overrides' => [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<ProjectTemplate>
|
||||||
|
*/
|
||||||
|
class ProjectTemplateFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'source_project_id' => Project::factory(),
|
||||||
|
'name' => Str::title(fake()->unique()->words(3, true)),
|
||||||
|
'slug' => fake()->unique()->slug(),
|
||||||
|
'copy_areas' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS],
|
||||||
|
'configuration_snapshot' => [
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
|
||||||
|
'show_author' => true,
|
||||||
|
'show_story_points' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('project_templates', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('source_project_id')->nullable()->constrained('projects')->nullOnDelete();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->json('copy_areas');
|
||||||
|
$table->json('configuration_snapshot');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['source_project_id', 'name']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('project_templates');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Actions\Projects\CreateProjectBaseTemplate;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
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::table('project_templates', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('project_id')->nullable()->after('id')->constrained('projects')->nullOnDelete();
|
||||||
|
$table->boolean('is_published')->default(true)->after('slug');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('projects', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('project_template_id')->nullable()->after('key')->constrained('project_templates')->nullOnDelete();
|
||||||
|
});
|
||||||
|
|
||||||
|
$projects = Project::query()->whereNull('project_template_id')->get();
|
||||||
|
$createProjectBaseTemplate = app(CreateProjectBaseTemplate::class);
|
||||||
|
|
||||||
|
foreach ($projects as $project) {
|
||||||
|
$baseTemplate = $createProjectBaseTemplate->handle($project);
|
||||||
|
|
||||||
|
$project->forceFill([
|
||||||
|
'project_template_id' => $baseTemplate->id,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectTemplate::query()
|
||||||
|
->whereNull('project_id')
|
||||||
|
->update(['is_published' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Project::query()->update(['project_template_id' => null]);
|
||||||
|
|
||||||
|
ProjectTemplate::query()->where('is_published', false)->delete();
|
||||||
|
|
||||||
|
Schema::table('projects', function (Blueprint $table): void {
|
||||||
|
$table->dropConstrainedForeignId('project_template_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('project_templates', function (Blueprint $table): void {
|
||||||
|
$table->dropConstrainedForeignId('project_id');
|
||||||
|
$table->dropColumn('is_published');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private array $tables = [
|
||||||
|
'board_columns',
|
||||||
|
'ticket_field_definitions',
|
||||||
|
'ticket_link_types',
|
||||||
|
'ticket_priorities',
|
||||||
|
'ticket_statuses',
|
||||||
|
'ticket_types',
|
||||||
|
'repository_connections',
|
||||||
|
'notification_actions',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
foreach ($this->tables as $table) {
|
||||||
|
Schema::table($table, function (Blueprint $table): void {
|
||||||
|
$table->foreignId('project_template_id')
|
||||||
|
->nullable()
|
||||||
|
->after('project_id')
|
||||||
|
->constrained('project_templates')
|
||||||
|
->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$projects = DB::table('projects')
|
||||||
|
->leftJoin('project_templates', 'project_templates.id', '=', 'projects.project_template_id')
|
||||||
|
->select([
|
||||||
|
'projects.id',
|
||||||
|
'projects.key',
|
||||||
|
'projects.name',
|
||||||
|
'projects.project_template_id',
|
||||||
|
'project_templates.project_id as template_project_id',
|
||||||
|
'project_templates.is_published as template_is_published',
|
||||||
|
])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($projects as $project) {
|
||||||
|
$projectTemplateId = $project->project_template_id;
|
||||||
|
|
||||||
|
if ($projectTemplateId === null || (
|
||||||
|
! (bool) $project->template_is_published
|
||||||
|
&& (int) ($project->template_project_id ?? 0) !== (int) $project->id
|
||||||
|
)) {
|
||||||
|
$projectTemplateId = DB::table('project_templates')->insertGetId([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'source_project_id' => $project->id,
|
||||||
|
'name' => $project->name.' base template',
|
||||||
|
'slug' => Str::lower($project->key).'-base-template',
|
||||||
|
'is_published' => false,
|
||||||
|
'copy_areas' => json_encode([
|
||||||
|
'project_settings',
|
||||||
|
'board_workflow',
|
||||||
|
'ticket_types',
|
||||||
|
'ticket_priorities',
|
||||||
|
'ticket_fields',
|
||||||
|
'ticket_link_types',
|
||||||
|
'repository_connections',
|
||||||
|
'notification_actions',
|
||||||
|
], JSON_THROW_ON_ERROR),
|
||||||
|
'configuration_snapshot' => json_encode([], JSON_THROW_ON_ERROR),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('projects')
|
||||||
|
->where('id', $project->id)
|
||||||
|
->update([
|
||||||
|
'project_template_id' => $projectTemplateId,
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->tables as $table) {
|
||||||
|
DB::table($table)
|
||||||
|
->where('project_id', $project->id)
|
||||||
|
->update(['project_template_id' => $projectTemplateId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectModel = Project::query()->find($project->id);
|
||||||
|
|
||||||
|
if ($projectModel !== null && ! (bool) $project->template_is_published) {
|
||||||
|
DB::table('project_templates')
|
||||||
|
->where('id', $projectTemplateId)
|
||||||
|
->update([
|
||||||
|
'configuration_snapshot' => json_encode(
|
||||||
|
app(DuplicateProjectConfiguration::class)->export(
|
||||||
|
$projectModel,
|
||||||
|
DuplicateProjectConfiguration::copyableAreas(),
|
||||||
|
),
|
||||||
|
JSON_THROW_ON_ERROR,
|
||||||
|
),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
foreach (array_reverse($this->tables) as $table) {
|
||||||
|
Schema::table($table, function (Blueprint $table): void {
|
||||||
|
$table->dropConstrainedForeignId('project_template_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('projects', function (Blueprint $table): void {
|
||||||
|
$table->json('template_overrides')->default('[]')->after('project_template_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
$configuration = app(DuplicateProjectConfiguration::class);
|
||||||
|
|
||||||
|
ProjectTemplate::query()->eachById(function (ProjectTemplate $template) use ($configuration): void {
|
||||||
|
$template->forceFill([
|
||||||
|
'copy_areas' => $configuration->normalizeCopyAreas($template->copy_areas ?? []),
|
||||||
|
'configuration_snapshot' => $configuration->normalizeConfiguration(
|
||||||
|
$template->configuration_snapshot ?? [],
|
||||||
|
),
|
||||||
|
])->saveQuietly();
|
||||||
|
});
|
||||||
|
|
||||||
|
Project::query()
|
||||||
|
->whereHas('projectTemplate', fn ($query) => $query->where('is_published', true))
|
||||||
|
->with('projectTemplate')
|
||||||
|
->eachById(function (Project $project) use ($configuration): void {
|
||||||
|
$templateConfiguration = $configuration->normalizeConfiguration(
|
||||||
|
$project->projectTemplate?->configuration_snapshot ?? [],
|
||||||
|
);
|
||||||
|
$areas = array_values(array_unique([
|
||||||
|
...array_keys($templateConfiguration),
|
||||||
|
...DuplicateProjectConfiguration::copyableAreas(),
|
||||||
|
]));
|
||||||
|
$currentConfiguration = $configuration->export($project, $areas);
|
||||||
|
$overrides = collect($areas)
|
||||||
|
->filter(fn (string $area): bool => ! array_key_exists($area, $templateConfiguration)
|
||||||
|
|| ($currentConfiguration[$area] ?? null) !== $templateConfiguration[$area])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('projects', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('template_overrides');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+32
-12
@@ -1,33 +1,38 @@
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
container_name: kanbangit-web
|
container_name: kanbangit-web
|
||||||
|
image: kanbangit-app:local
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./docker/nginx/Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: runtime
|
||||||
|
command: nginx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:8080"
|
||||||
volumes:
|
|
||||||
- kanbangit-storage:/var/www/storage:ro
|
|
||||||
- kanbangit-public-assets:/var/www/public/build:ro
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env.production
|
- .env.production
|
||||||
|
environment:
|
||||||
|
PHP_FPM_UPSTREAM: php-fpm:9000
|
||||||
networks:
|
networks:
|
||||||
- kanbangit-web
|
- kanbangit-web
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8080/up"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
depends_on:
|
depends_on:
|
||||||
php-fpm:
|
php-fpm:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
php-fpm:
|
php-fpm:
|
||||||
container_name: kanbangit-php-fpm
|
container_name: kanbangit-php-fpm
|
||||||
|
image: kanbangit-app:local
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./docker/php-fpm/Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: production
|
target: runtime
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
|
||||||
- kanbangit-storage:/var/www/storage
|
|
||||||
- kanbangit-public-assets:/var/www/public/build
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env.production
|
- .env.production
|
||||||
networks:
|
networks:
|
||||||
@@ -42,6 +47,23 @@ services:
|
|||||||
mariadb:
|
mariadb:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
|
worker:
|
||||||
|
container_name: kanbangit-worker
|
||||||
|
image: kanbangit-app:local
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: runtime
|
||||||
|
command: php artisan queue:work --sleep=3 --tries=3 --timeout=60 --max-time=3600
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env.production
|
||||||
|
networks:
|
||||||
|
- kanbangit-db
|
||||||
|
depends_on:
|
||||||
|
mariadb:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
mariadb:
|
mariadb:
|
||||||
container_name: kanbangit-db
|
container_name: kanbangit-db
|
||||||
image: mariadb:11.4
|
image: mariadb:11.4
|
||||||
@@ -65,5 +87,3 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mariadb-data-production:
|
mariadb-data-production:
|
||||||
kanbangit-storage:
|
|
||||||
kanbangit-public-assets:
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ "${1:-}" = "nginx" ]; then
|
||||||
|
: "${PHP_FPM_UPSTREAM:=127.0.0.1:9000}"
|
||||||
|
export PHP_FPM_UPSTREAM
|
||||||
|
|
||||||
|
envsubst '${PHP_FPM_UPSTREAM}' \
|
||||||
|
< /etc/nginx/nginx.conf.template \
|
||||||
|
> /tmp/nginx.conf
|
||||||
|
|
||||||
|
exec nginx -c /tmp/nginx.conf -g 'daemon off;'
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p \
|
||||||
|
bootstrap/cache \
|
||||||
|
storage/framework/cache \
|
||||||
|
storage/framework/sessions \
|
||||||
|
storage/framework/testing \
|
||||||
|
storage/framework/views \
|
||||||
|
storage/logs
|
||||||
|
|
||||||
|
php artisan config:cache --no-ansi
|
||||||
|
php artisan route:cache --no-ansi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
FROM php:8.4-cli-bookworm AS builder
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
curl ca-certificates git unzip \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
|
||||||
&& apt-get update && apt-get install -y --no-install-recommends nodejs \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
RUN curl -sS https://getcomposer.org/installer | php -- \
|
|
||||||
--install-dir=/usr/local/bin --filename=composer
|
|
||||||
WORKDIR /var/www
|
|
||||||
COPY . /var/www
|
|
||||||
RUN composer install --no-dev --prefer-dist --no-interaction --no-progress --optimize-autoloader
|
|
||||||
RUN npm ci && npm run build
|
|
||||||
|
|
||||||
FROM nginx:alpine
|
|
||||||
COPY ./docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
|
||||||
COPY --from=builder /var/www/public /var/www/public
|
|
||||||
WORKDIR /var/www/public
|
|
||||||
EXPOSE 80
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
events {
|
|
||||||
worker_connections 1024;
|
|
||||||
}
|
|
||||||
http {
|
|
||||||
include /etc/nginx/mime.types;
|
|
||||||
default_type application/octet-stream;
|
|
||||||
sendfile on;
|
|
||||||
keepalive_timeout 65;
|
|
||||||
client_max_body_size 100m;
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name localhost;
|
|
||||||
root /var/www/public;
|
|
||||||
index index.php index.html;
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.php?$query_string;
|
|
||||||
}
|
|
||||||
location ^~ /storage/ {
|
|
||||||
alias /var/www/storage/app/public/;
|
|
||||||
try_files $uri =404;
|
|
||||||
access_log off;
|
|
||||||
expires 7d;
|
|
||||||
add_header Cache-Control "public";
|
|
||||||
}
|
|
||||||
location ~ \.php$ {
|
|
||||||
fastcgi_pass php-fpm:9000;
|
|
||||||
fastcgi_index index.php;
|
|
||||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
|
||||||
include fastcgi_params;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
pid /tmp/nginx.pid;
|
||||||
|
error_log /dev/stderr warn;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
access_log /dev/stdout;
|
||||||
|
client_body_temp_path /tmp/client_body;
|
||||||
|
proxy_temp_path /tmp/proxy;
|
||||||
|
fastcgi_temp_path /tmp/fastcgi;
|
||||||
|
uwsgi_temp_path /tmp/uwsgi;
|
||||||
|
scgi_temp_path /tmp/scgi;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
client_max_body_size 3m;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
root /var/www/public;
|
||||||
|
index index.php;
|
||||||
|
|
||||||
|
location = /nginx-health {
|
||||||
|
access_log off;
|
||||||
|
default_type text/plain;
|
||||||
|
return 200 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.php?$query_string;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /index.php {
|
||||||
|
include /etc/nginx/fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
fastcgi_param HTTP_PROXY '';
|
||||||
|
fastcgi_param HTTP_X_FORWARDED_FOR $http_x_forwarded_for;
|
||||||
|
fastcgi_param HTTP_X_FORWARDED_HOST $http_x_forwarded_host;
|
||||||
|
fastcgi_param HTTP_X_FORWARDED_PORT $http_x_forwarded_port;
|
||||||
|
fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto;
|
||||||
|
fastcgi_pass ${PHP_FPM_UPSTREAM};
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_NAME=/status \
|
||||||
|
SCRIPT_FILENAME=/status \
|
||||||
|
REQUEST_METHOD=GET \
|
||||||
|
cgi-fcgi -bind -connect 127.0.0.1:9000 2>/dev/null | grep -q '^pool:'
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
FROM php:8.4-fpm AS builder
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
curl \
|
|
||||||
unzip \
|
|
||||||
libpq-dev \
|
|
||||||
libonig-dev \
|
|
||||||
libssl-dev \
|
|
||||||
libxml2-dev \
|
|
||||||
libcurl4-openssl-dev \
|
|
||||||
libicu-dev \
|
|
||||||
libzip-dev \
|
|
||||||
libmagickwand-dev \
|
|
||||||
libpng-dev \
|
|
||||||
libjpeg-dev \
|
|
||||||
libfreetype6-dev \
|
|
||||||
&& docker-php-ext-install -j$(nproc) \
|
|
||||||
pdo_mysql \
|
|
||||||
pdo_pgsql \
|
|
||||||
pgsql \
|
|
||||||
opcache \
|
|
||||||
intl \
|
|
||||||
zip \
|
|
||||||
bcmath \
|
|
||||||
soap \
|
|
||||||
&& docker-php-ext-configure gd --with-jpeg --with-freetype \
|
|
||||||
&& docker-php-ext-install -j$(nproc) gd \
|
|
||||||
&& pecl install imagick \
|
|
||||||
&& docker-php-ext-enable imagick \
|
|
||||||
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
|
||||||
WORKDIR /var/www
|
|
||||||
COPY . /var/www
|
|
||||||
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
|
|
||||||
&& composer install --no-dev --optimize-autoloader --no-interaction --no-progress --prefer-dist
|
|
||||||
FROM php:8.4-fpm AS production
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
libpq-dev \
|
|
||||||
libicu-dev \
|
|
||||||
libzip-dev \
|
|
||||||
libfcgi-bin \
|
|
||||||
procps \
|
|
||||||
libmagickwand-dev \
|
|
||||||
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
|
||||||
RUN curl -o /usr/local/bin/php-fpm-healthcheck \
|
|
||||||
https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/master/php-fpm-healthcheck \
|
|
||||||
&& chmod +x /usr/local/bin/php-fpm-healthcheck
|
|
||||||
COPY ./docker/php-fpm/entrypoint.sh /usr/local/bin/entrypoint.sh
|
|
||||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
|
||||||
COPY ./storage /var/www/storage-init
|
|
||||||
COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/
|
|
||||||
COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/
|
|
||||||
COPY --from=builder /usr/local/bin/docker-php-ext-* /usr/local/bin/
|
|
||||||
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
|
||||||
RUN printf '%s\n' \
|
|
||||||
'[global]' \
|
|
||||||
'daemonize = no' \
|
|
||||||
'' \
|
|
||||||
'[www]' \
|
|
||||||
'pm.status_path = /status' \
|
|
||||||
> /usr/local/etc/php-fpm.d/zz-docker.conf
|
|
||||||
COPY --from=builder /var/www /var/www
|
|
||||||
WORKDIR /var/www
|
|
||||||
RUN chown -R www-data:www-data /var/www
|
|
||||||
USER www-data
|
|
||||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
|
||||||
EXPOSE 9000
|
|
||||||
CMD ["php-fpm"]
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
if [ ! "$(ls -A /var/www/storage)" ]; then
|
|
||||||
echo "Initializing storage directory..."
|
|
||||||
cp -R /var/www/storage-init/. /var/www/storage
|
|
||||||
chown -R www-data:www-data /var/www/storage
|
|
||||||
fi
|
|
||||||
rm -rf /var/www/storage-init
|
|
||||||
php artisan config:cache
|
|
||||||
php artisan route:cache
|
|
||||||
exec "$@"
|
|
||||||
@@ -25,6 +25,7 @@ const prettierOptions = {
|
|||||||
const eslintConfig = [
|
const eslintConfig = [
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
|
'bootstrap/ssr/**',
|
||||||
'**/dist/**',
|
'**/dist/**',
|
||||||
'node_modules/**',
|
'node_modules/**',
|
||||||
'.github/**',
|
'.github/**',
|
||||||
@@ -37,7 +38,10 @@ const eslintConfig = [
|
|||||||
'bootstrap/cache/**',
|
'bootstrap/cache/**',
|
||||||
'public/build/**',
|
'public/build/**',
|
||||||
'public/hot',
|
'public/hot',
|
||||||
|
'resources/js/actions/**',
|
||||||
'resources/js/components/ui/**',
|
'resources/js/components/ui/**',
|
||||||
|
'resources/js/routes/**',
|
||||||
|
'resources/js/wayfinder/**',
|
||||||
'resources/views/mail/**',
|
'resources/views/mail/**',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
-6969
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,13 @@
|
|||||||
"$schema": "https://www.schemastore.org/package.json",
|
"$schema": "https://www.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"devEngines": {
|
||||||
|
"packageManager": {
|
||||||
|
"name": "pnpm",
|
||||||
|
"version": "11.26.0+sha512.fc0e2bf890b9f983611f1ab68c0637bce914390653699f83c1a78b005ed25f2c81e77920c8fd8eee2ecf0b58b28cdcb00a84d97f69bcf7c56b2f344710238664",
|
||||||
|
"onFail": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:ssr": "vite build && vite build --ssr",
|
"build:ssr": "vite build && vite build --ssr",
|
||||||
@@ -39,6 +46,7 @@
|
|||||||
"vue-tsc": "^2.2.4"
|
"vue-tsc": "^2.2.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@inertiajs/core": "2.3.18",
|
||||||
"@inertiajs/vue3": "^2.3.7",
|
"@inertiajs/vue3": "^2.3.7",
|
||||||
"@vueuse/core": "^12.8.2",
|
"@vueuse/core": "^12.8.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
<php>
|
<php>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<env name="APP_ENV" value="testing"/>
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
|
<env name="AVATAR_FILESYSTEM_DISK" value="public"/>
|
||||||
|
<env name="AVATAR_URLS_TEMPORARY" value="false"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="array"/>
|
||||||
|
|||||||
Generated
+4832
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
minimumReleaseAge: 10080
|
||||||
|
allowBuilds:
|
||||||
|
unrs-resolver: true
|
||||||
|
vue-demi: true
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,92 @@
|
|||||||
|
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION AND CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user