Compare commits

..
19 Commits
Author SHA1 Message Date
Twyco 0f125f17a3 Merge branch 'main' into epic/kan-3
linter / quality (pull_request) Successful in 1m30s
tests / ci (8.4) (pull_request) Successful in 1m54s
2026-06-25 01:25:53 +02:00
Twyco 5e7be28daf Merge pull request 'Fix KAN-3 project template flows' (#103) from codex/kan-3-template-fixes into epic/kan-3
linter / quality (pull_request) Successful in 1m36s
tests / ci (8.4) (pull_request) Successful in 1m51s
Reviewed-on: OpenSource/KanbanGit#103
2026-06-24 10:56:35 +00:00
Twyco d250f0b380 Fix KAN-3 project template flows
linter / quality (pull_request) Successful in 1m31s
tests / ci (8.4) (pull_request) Successful in 1m47s
2026-06-24 01:16:25 +02:00
Twyco c19cf34505 Merge pull request 'kan-11' (#99) from koda/kan-11-restliche-projekt-konfiguration-auf-templates-umstellen into epic/kan-3
linter / quality (pull_request) Successful in 1m36s
tests / ci (8.4) (pull_request) Failing after 1m51s
Reviewed-on: OpenSource/KanbanGit#99
2026-06-23 22:41:16 +00:00
Twyco 37b2e6034f Merge pull request 'koda/kan-10-edit-templates' (#102) from koda/kan-10-edit-templates into epic/kan-3
linter / quality (pull_request) Successful in 1m31s
tests / ci (8.4) (pull_request) Successful in 1m52s
Reviewed-on: OpenSource/KanbanGit#102
2026-06-23 22:40:46 +00:00
Twyco 84e427ef5d Merge pull request 'koda/kan-9-switchreset-template-for-existing-projects' (#101) from koda/kan-9-switchreset-template-for-existing-projects into epic/kan-3
linter / quality (pull_request) Successful in 1m31s
tests / ci (8.4) (pull_request) Successful in 1m42s
Reviewed-on: OpenSource/KanbanGit#101
2026-06-23 22:40:38 +00:00
Twyco 95b53ba74b Merge pull request 'Add template context to API and MCP project payloads' (#100) from koda/kan-8-add-project-template-to-mcp-api into epic/kan-3
linter / quality (pull_request) Successful in 1m24s
tests / ci (8.4) (pull_request) Successful in 1m46s
Reviewed-on: OpenSource/KanbanGit#100
2026-06-23 22:40:29 +00:00
Twyco a8952d7867 Merge pull request 'KAN-7 Made Templates Overwritable' (#89) from koda/kan-7-made-templates-overwritable into epic/kan-3
linter / quality (pull_request) Successful in 1m28s
tests / ci (8.4) (pull_request) Successful in 1m48s
Reviewed-on: OpenSource/KanbanGit#89
Reviewed-by: Eric <eric-peters6@gmx.de>
2026-06-23 21:17:52 +00:00
Koda 2390daf2de Move remaining project metadata ownership to templates
linter / quality (pull_request) Successful in 1m26s
tests / ci (8.4) (pull_request) Successful in 1m49s
2026-06-05 20:31:38 +00:00
Twyco b1357cf854 Merge pull request 'KAN-6 Project rely on project templates' (#88) from koda/kan-6-project-rely-on-project-templates into epic/kan-3
Reviewed-on: OpenSource/KanbanGit#88
2026-06-04 20:06:22 +00:00
Twyco 2c229c9b39 Merge pull request 'KAN-5 Create Template from Project & Create Project form Template' (#87) from koda/kan-5-create-template-from-project-create-project-form-template into epic/kan-3
Reviewed-on: OpenSource/KanbanGit#87
2026-06-04 19:44:32 +00:00
Twyco 9a7dd7fb59 Merge pull request 'KAN-4 Duplicate Project' (#86) from koda/kan-4-duplicate-project into epic/kan-3
Reviewed-on: OpenSource/KanbanGit#86
Reviewed-by: Twyco <twyco@icloud.com>
2026-06-04 19:21:11 +00:00
Koda 39a039cd1e Add editable project template flow 2026-05-25 11:12:55 +00:00
Koda e38c77198d Add template switch and reset flows for existing projects 2026-05-25 08:31:35 +00:00
Koda ee9b9a2765 Add template context to API and MCP project payloads 2026-05-25 08:19:09 +00:00
Koda 57711b9659 Show template inheritance overrides 2026-05-25 01:53:03 +00:00
Koda 5e6426067a Make projects depend on templates 2026-05-25 01:41:27 +00:00
Koda 58d2edcacd Add reusable project templates 2026-05-25 01:26:08 +00:00
Koda 3b17c9ee14 Add project duplication flow
linter / quality (pull_request) Successful in 2m11s
tests / ci (8.4) (pull_request) Successful in 1m46s
2026-05-24 22:36:21 +00:00
104 changed files with 4833 additions and 1957 deletions
-96
View File
@@ -1,96 +0,0 @@
---
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
+7 -6
View File
@@ -25,6 +25,7 @@
**/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
@@ -40,9 +41,9 @@ vendor
LICENSE LICENSE
README.md README.md
# Runtime-generated data must not become part of an image layer. # Ignore development-related files and directories
storage/framework/cache/data/* # For development environment, we will use mount bind to mount the source code to the container
storage/framework/sessions/* # So we don't need to copy the source code to the container for development
storage/framework/testing/* tests/
storage/framework/views/* phpunit.xml
storage/logs/* phpunit.xml.dist
+1 -17
View File
@@ -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=stderr LOG_STACK=daily
# 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,22 +38,6 @@ 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,18 +0,0 @@
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 \
.
-130
View File
@@ -1,130 +0,0 @@
name: CI/CD
on:
push:
pull_request:
permissions:
contents: read
env:
REGISTRY: git.twyco.de
jobs:
test_and_check_app:
name: test_and_check_app
runs-on: ubuntu-latest
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:
- 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
+39
View File
@@ -0,0 +1,39 @@
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'
+49
View File
@@ -0,0 +1,49 @@
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
+2 -2
View File
@@ -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.
- `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. - `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.
- `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.
-109
View File
@@ -1,109 +0,0 @@
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 \
&& npm ci \
&& npm run build
FROM build AS test
RUN touch .env \
&& 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(),
]; ];
} }
@@ -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
+58 -3
View File
@@ -6,25 +6,80 @@ 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,
) {} ) {}
/** /**
* @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;
if ($creationMode === 'template' && $sourceTemplateId !== null) {
$sourceTemplate = ProjectTemplate::query()
->published()
->findOrFail((int) $sourceTemplateId);
$project->forceFill([
'project_template_id' => $sourceTemplate->id,
])->save();
} else {
$baseTemplate = $this->createProjectBaseTemplate->handle($project);
$project->forceFill([
'project_template_id' => $baseTemplate->id,
])->save();
}
$project = $project->fresh() ?? $project;
$this->initializeProjectTicketMetadata->handle($project); $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,400 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
class DuplicateProjectConfiguration
{
public const COPY_PROJECT_SETTINGS = 'project_settings';
public const COPY_BOARD_WORKFLOW = 'board_workflow';
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';
/**
* @return list<string>
*/
public static function copyableAreas(): array
{
return [
self::COPY_PROJECT_SETTINGS,
self::COPY_BOARD_WORKFLOW,
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<array{value: string, label: string, description: string}>
*/
public static function copyAreaOptions(): array
{
return [
[
'value' => self::COPY_PROJECT_SETTINGS,
'label' => 'Project settings',
'description' => 'Copy general project preferences like story points and author visibility.',
],
[
'value' => self::COPY_BOARD_WORKFLOW,
'label' => 'Board workflow',
'description' => 'Copy board columns, ticket statuses, and allowed status transitions.',
],
[
'value' => self::COPY_TICKET_TYPES,
'label' => 'Ticket types',
'description' => 'Copy available ticket types, including parent-ticket types.',
],
[
'value' => self::COPY_TICKET_PRIORITIES,
'label' => 'Ticket priorities',
'description' => 'Copy the project-specific ticket priorities.',
],
[
'value' => self::COPY_TICKET_FIELDS,
'label' => 'Custom ticket fields',
'description' => 'Copy custom ticket field definitions without any ticket values.',
],
[
'value' => self::COPY_TICKET_LINK_TYPES,
'label' => 'Ticket link types',
'description' => 'Copy available relationship labels between tickets.',
],
[
'value' => self::COPY_REPOSITORY_CONNECTIONS,
'label' => 'Repository connections',
'description' => 'Copy linked repositories without changing the new project identity.',
],
[
'value' => self::COPY_NOTIFICATION_ACTIONS,
'label' => 'Notification actions',
'description' => 'Copy notification rules and delivery settings.',
],
];
}
/**
* @param list<string> $copyAreas
* @return list<string>
*/
public function normalizeCopyAreas(array $copyAreas): array
{
return array_values(array_intersect(self::copyableAreas(), array_unique($copyAreas)));
}
/**
* @param list<string> $copyAreas
* @return array<string, mixed>
*/
public function export(Project $sourceProject, array $copyAreas): array
{
$selectedAreas = $this->normalizeCopyAreas($copyAreas);
if ($selectedAreas === []) {
return [];
}
$sourceProject->loadMissing([
'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,
];
}
if (in_array(self::COPY_BOARD_WORKFLOW, $selectedAreas, true)) {
$boardColumnIndexes = [];
$boardColumns = [];
foreach ($sourceProject->boardColumns as $index => $boardColumn) {
$boardColumnIndexes[$boardColumn->id] = $index;
$boardColumns[] = [
'name' => $boardColumn->name,
'position' => $boardColumn->position,
];
}
$ticketStatusIndexes = [];
$ticketStatuses = [];
foreach ($sourceProject->ticketStatuses as $index => $ticketStatus) {
$ticketStatusIndexes[$ticketStatus->id] = $index;
$ticketStatuses[] = [
'board_column_index' => $ticketStatus->board_column_id === null
? null
: ($boardColumnIndexes[$ticketStatus->board_column_id] ?? null),
'name' => $ticketStatus->name,
'position' => $ticketStatus->position,
'allowed_target_indexes' => [],
];
}
foreach ($sourceProject->ticketStatuses as $index => $ticketStatus) {
$ticketStatuses[$index]['allowed_target_indexes'] = $ticketStatus->allowedTargetStatuses
->pluck('id')
->map(fn (int $statusId): ?int => $ticketStatusIndexes[$statusId] ?? null)
->filter(static fn (?int $statusIndex): bool => $statusIndex !== null)
->values()
->all();
}
$configuration[self::COPY_BOARD_WORKFLOW] = [
'board_columns' => $boardColumns,
'ticket_statuses' => $ticketStatuses,
];
}
if (in_array(self::COPY_TICKET_TYPES, $selectedAreas, true)) {
$configuration[self::COPY_TICKET_TYPES] = $sourceProject->ticketTypes
->map(fn ($ticketType): array => [
'name' => $ticketType->name,
'position' => $ticketType->position,
'is_parent' => $ticketType->is_parent,
])
->values()
->all();
}
if (in_array(self::COPY_TICKET_PRIORITIES, $selectedAreas, true)) {
$configuration[self::COPY_TICKET_PRIORITIES] = $sourceProject->ticketPriorities
->map(fn ($ticketPriority): array => [
'name' => $ticketPriority->name,
'position' => $ticketPriority->position,
])
->values()
->all();
}
if (in_array(self::COPY_TICKET_FIELDS, $selectedAreas, true)) {
$configuration[self::COPY_TICKET_FIELDS] = $sourceProject->ticketFieldDefinitions
->map(fn ($ticketFieldDefinition): array => [
'field_type' => $ticketFieldDefinition->field_type,
'label' => $ticketFieldDefinition->label,
'position' => $ticketFieldDefinition->position,
])
->values()
->all();
}
if (in_array(self::COPY_TICKET_LINK_TYPES, $selectedAreas, true)) {
$configuration[self::COPY_TICKET_LINK_TYPES] = $sourceProject->ticketLinkTypes
->map(fn ($ticketLinkType): array => [
'label' => $ticketLinkType->label,
'counterpart_label' => $ticketLinkType->counterpart_label,
'position' => $ticketLinkType->position,
])
->values()
->all();
}
if (in_array(self::COPY_REPOSITORY_CONNECTIONS, $selectedAreas, true)) {
$configuration[self::COPY_REPOSITORY_CONNECTIONS] = $sourceProject->repositoryConnections
->map(fn ($repositoryConnection): array => [
'provider' => $repositoryConnection->provider->value,
'base_url' => $repositoryConnection->base_url,
'provider_repository_id' => $repositoryConnection->provider_repository_id,
'owner_name' => $repositoryConnection->owner_name,
'repository_name' => $repositoryConnection->repository_name,
'repository_full_name' => $repositoryConnection->repository_full_name,
'repository_url' => $repositoryConnection->repository_url,
])
->values()
->all();
}
if (in_array(self::COPY_NOTIFICATION_ACTIONS, $selectedAreas, true)) {
$configuration[self::COPY_NOTIFICATION_ACTIONS] = $sourceProject->notificationActions
->map(fn ($notificationAction): array => [
'name' => $notificationAction->name,
'enabled' => $notificationAction->enabled,
'event_type' => $notificationAction->event_type->value,
'channel' => $notificationAction->channel->value,
'channel_configuration' => $notificationAction->channel_configuration ?? [],
'message_template' => $notificationAction->message_template,
'delivery_url' => $notificationAction->delivery_url,
'filters' => $notificationAction->filters ?? [],
])
->values()
->all();
}
return $configuration;
}
/**
* @param array<string, mixed> $configuration
*/
public function apply(Project $targetProject, array $configuration): void
{
if (array_key_exists(self::COPY_PROJECT_SETTINGS, $configuration)) {
$projectSettings = $configuration[self::COPY_PROJECT_SETTINGS];
$targetProject->forceFill([
'show_author' => (bool) ($projectSettings['show_author'] ?? $targetProject->show_author),
'show_story_points' => (bool) ($projectSettings['show_story_points'] ?? $targetProject->show_story_points),
])->save();
}
if (array_key_exists(self::COPY_BOARD_WORKFLOW, $configuration)) {
$targetProject->ticketStatuses()->delete();
$targetProject->boardColumns()->delete();
$boardColumnIds = [];
foreach (($configuration[self::COPY_BOARD_WORKFLOW]['board_columns'] ?? []) as $boardColumn) {
$newBoardColumn = $targetProject->boardColumns()->create([
'name' => $boardColumn['name'],
'position' => $boardColumn['position'],
]);
$boardColumnIds[] = $newBoardColumn->id;
}
$ticketStatusIds = [];
foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $ticketStatus) {
$boardColumnIndex = $ticketStatus['board_column_index'] ?? null;
$newTicketStatus = $targetProject->ticketStatuses()->create([
'board_column_id' => $boardColumnIndex === null ? null : ($boardColumnIds[$boardColumnIndex] ?? null),
'name' => $ticketStatus['name'],
'position' => $ticketStatus['position'],
]);
$ticketStatusIds[] = $newTicketStatus->id;
}
foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $index => $ticketStatus) {
$newTicketStatusId = $ticketStatusIds[$index] ?? null;
if ($newTicketStatusId === null) {
continue;
}
$targetProject->ticketStatuses()->findOrFail($newTicketStatusId)
->allowedTargetStatuses()
->sync(
collect($ticketStatus['allowed_target_indexes'] ?? [])
->map(fn (int $statusIndex): ?int => $ticketStatusIds[$statusIndex] ?? null)
->filter(static fn (?int $statusId): bool => $statusId !== null)
->values()
->all(),
);
}
}
if (array_key_exists(self::COPY_TICKET_TYPES, $configuration)) {
$targetProject->ticketTypes()->delete();
foreach ($configuration[self::COPY_TICKET_TYPES] as $ticketType) {
$targetProject->ticketTypes()->create([
'name' => $ticketType['name'],
'position' => $ticketType['position'],
'is_parent' => $ticketType['is_parent'],
]);
}
}
if (array_key_exists(self::COPY_TICKET_PRIORITIES, $configuration)) {
$targetProject->ticketPriorities()->delete();
foreach ($configuration[self::COPY_TICKET_PRIORITIES] as $ticketPriority) {
$targetProject->ticketPriorities()->create([
'name' => $ticketPriority['name'],
'position' => $ticketPriority['position'],
]);
}
}
if (array_key_exists(self::COPY_TICKET_FIELDS, $configuration)) {
$targetProject->ticketFieldDefinitions()->delete();
foreach ($configuration[self::COPY_TICKET_FIELDS] as $ticketFieldDefinition) {
$targetProject->ticketFieldDefinitions()->create([
'field_type' => $ticketFieldDefinition['field_type'],
'label' => $ticketFieldDefinition['label'],
'position' => $ticketFieldDefinition['position'],
]);
}
}
if (array_key_exists(self::COPY_TICKET_LINK_TYPES, $configuration)) {
$targetProject->ticketLinkTypes()->delete();
foreach ($configuration[self::COPY_TICKET_LINK_TYPES] as $ticketLinkType) {
$targetProject->ticketLinkTypes()->create([
'label' => $ticketLinkType['label'],
'counterpart_label' => $ticketLinkType['counterpart_label'],
'position' => $ticketLinkType['position'],
]);
}
}
if (array_key_exists(self::COPY_REPOSITORY_CONNECTIONS, $configuration)) {
$targetProject->repositoryConnections()->delete();
foreach ($configuration[self::COPY_REPOSITORY_CONNECTIONS] as $repositoryConnection) {
$targetProject->repositoryConnections()->create([
'provider' => $repositoryConnection['provider'],
'base_url' => $repositoryConnection['base_url'],
'provider_repository_id' => $repositoryConnection['provider_repository_id'],
'owner_name' => $repositoryConnection['owner_name'],
'repository_name' => $repositoryConnection['repository_name'],
'repository_full_name' => $repositoryConnection['repository_full_name'],
'repository_url' => $repositoryConnection['repository_url'],
]);
}
}
if (array_key_exists(self::COPY_NOTIFICATION_ACTIONS, $configuration)) {
$targetProject->notificationActions()->delete();
foreach ($configuration[self::COPY_NOTIFICATION_ACTIONS] as $notificationAction) {
$targetProject->notificationActions()->create([
'name' => $notificationAction['name'],
'enabled' => $notificationAction['enabled'],
'event_type' => $notificationAction['event_type'],
'channel' => $notificationAction['channel'],
'channel_configuration' => $notificationAction['channel_configuration'] ?? [],
'message_template' => $notificationAction['message_template'] ?? null,
'delivery_url' => $notificationAction['delivery_url'],
'filters' => $notificationAction['filters'] ?? [],
]);
}
}
}
/**
* @param list<string> $copyAreas
*/
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
{
$this->apply($targetProject, $this->export($sourceProject, $copyAreas));
}
}
@@ -0,0 +1,92 @@
<?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);
$templateConfiguration = collect($projectTemplate->configuration_snapshot ?? [])
->only($selectedAreas)
->all();
$currentConfiguration = $this->duplicateProjectConfiguration->export($project, $selectedAreas);
$overriddenAreas = [];
$inheritedAreas = [];
foreach ($selectedAreas as $area) {
$label = $this->labelForArea($area);
$hasTemplateValue = array_key_exists($area, $templateConfiguration);
$hasCurrentValue = array_key_exists($area, $currentConfiguration);
if (! $hasTemplateValue && ! $hasCurrentValue) {
continue;
}
if ($hasTemplateValue && $hasCurrentValue && $currentConfiguration[$area] === $templateConfiguration[$area]) {
$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),
'templateConfiguration' => $templateConfiguration,
'templateId' => $projectTemplate->id,
'templateName' => $projectTemplate->name,
'templateSlug' => $projectTemplate->slug,
];
}
private function labelForArea(string $area): string
{
foreach (DuplicateProjectConfiguration::copyAreaOptions() as $option) {
if ($option['value'] === $area) {
return $option['label'];
}
}
return $area;
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Actions\Projects;
use App\Models\ProjectTemplate;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class UpdateProjectTemplate
{
public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
) {}
/**
* @param array{name: string, copy_areas?: array<int, string>} $attributes
*/
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
{
$projectTemplate->loadMissing('sourceProject', 'linkedProjects.projectTemplate');
$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 = $projectTemplate->configuration_snapshot ?? [];
$nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas);
$affectedAreas = array_values(array_unique([
...array_keys($previousConfiguration),
...array_keys($nextConfiguration),
]));
DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void {
$projectTemplate->forceFill([
'name' => $attributes['name'],
'copy_areas' => $normalizedCopyAreas,
'configuration_snapshot' => $nextConfiguration,
])->save();
foreach ($projectTemplate->linkedProjects as $linkedProject) {
$currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject->fresh(), $affectedAreas);
$replacementConfiguration = [];
foreach ($nextConfiguration as $area => $configuration) {
$isNewTemplateArea = ! array_key_exists($area, $previousConfiguration);
$matchesPreviousTemplate = array_key_exists($area, $previousConfiguration)
&& array_key_exists($area, $currentConfiguration)
&& $currentConfiguration[$area] === $previousConfiguration[$area];
if ($isNewTemplateArea || $matchesPreviousTemplate) {
$replacementConfiguration[$area] = $configuration;
}
}
if ($replacementConfiguration === []) {
continue;
}
$this->duplicateProjectConfiguration->apply($linkedProject->fresh(), $replacementConfiguration);
}
});
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
}
}
@@ -0,0 +1,44 @@
<?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,
) {}
public function switch(Project $project, ProjectTemplate $template): void
{
DB::transaction(function () use ($project, $template): void {
$project->forceFill([
'project_template_id' => $template->id,
])->save();
$this->duplicateProjectConfiguration->apply(
$project->fresh(),
$template->configuration_snapshot ?? [],
);
});
}
public function reset(Project $project): void
{
$template = $project->projectTemplate;
if ($template === null) {
return;
}
DB::transaction(function () use ($project, $template): void {
$this->duplicateProjectConfiguration->apply(
$project->fresh(),
$template->configuration_snapshot ?? [],
);
});
}
}
@@ -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,
]),
]; ];
} }
+7 -3
View File
@@ -47,9 +47,13 @@ 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(
->findOrFail(Arr::get($attributes, 'ticket_status_id')); $project->project_template_id === null ? 'project_id' : 'project_template_id',
$project->project_template_id ?? $project->id,
);
$ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id'));
$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'),
+3 -11
View File
@@ -30,10 +30,9 @@ 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($this->avatarDisk())->put( $stored = Storage::disk('public')->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) {
@@ -54,7 +53,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', $this->avatarDisk()); $path = $avatar->store('avatars/uploads', 'public');
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.');
@@ -91,14 +90,7 @@ class UpdateUserAvatar
return; return;
} }
Storage::disk($this->avatarDisk())->delete($path); Storage::disk('public')->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
-11
View File
@@ -8,7 +8,6 @@ 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}')]
@@ -26,9 +25,6 @@ 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'),
@@ -42,14 +38,7 @@ 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;
}
} }
} }
+61 -1
View File
@@ -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;
@@ -32,6 +39,8 @@ class ProjectController extends Controller
private readonly DeleteProject $deleteProject, private readonly DeleteProject $deleteProject,
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 +60,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 +98,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 +121,44 @@ 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
{
$template = ProjectTemplate::query()
->published()
->findOrFail($request->integer('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
{
$request->validated();
$template = $project->projectTemplate;
abort_if($template === null, 422, 'This project has no linked template to reset.');
$this->updateProjectTemplateLink->reset($project->load('projectTemplate'));
return to_route('projects.settings.general', $project)
->with('status', "{$project->name} was reset to the {$template->name} template baseline.");
}
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.");
}
}
@@ -24,12 +24,17 @@ class MoveProjectTicketRequest extends FormRequest
{ {
/** @var Project $project */ /** @var Project $project */
$project = $this->route('project'); $project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [ return [
'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($ownershipColumn, $ownershipValue),
),
], ],
]; ];
} }
@@ -29,12 +29,17 @@ class DestroyProjectTicketTypeRequest extends FormRequest
{ {
/** @var Project $project */ /** @var Project $project */
$project = $this->route('project'); $project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [ return [
'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($ownershipColumn, $ownershipValue),
),
], ],
]; ];
} }
@@ -29,12 +29,17 @@ class MoveProjectTicketRequest extends FormRequest
{ {
/** @var Project $project */ /** @var Project $project */
$project = $this->route('project'); $project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [ return [
'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($ownershipColumn, $ownershipValue),
),
], ],
'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])], 'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])],
]; ];
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests;
use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
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 [
'confirmation_key' => [
'required',
'string',
Rule::in([$project->key]),
],
];
}
/**
* @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.',
];
}
}
+59
View File
@@ -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::copyableAreas()),
],
];
}
/**
* @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::copyableAreas())],
];
}
}
@@ -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,21 @@ class StoreProjectTicketBranchRequest extends FormRequest
*/ */
public function rules(): array public function rules(): array
{ {
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
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($ownershipColumn, $ownershipValue),
),
],
]; ];
} }
} }
@@ -48,6 +48,9 @@ class StoreProjectTicketRequest extends FormRequest
{ {
/** @var Project $project */ /** @var Project $project */
$project = $this->route('project'); $project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [ return [
'acceptance_criteria' => ['nullable', 'array'], 'acceptance_criteria' => ['nullable', 'array'],
@@ -66,17 +69,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($ownershipColumn, $ownershipValue),
),
], ],
'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($ownershipColumn, $ownershipValue),
),
], ],
'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($ownershipColumn, $ownershipValue),
),
], ],
'story_points' => ['nullable', 'integer', 'min:0'], 'story_points' => ['nullable', 'integer', 'min:0'],
'title' => ['required', 'string', 'max:255'], 'title' => ['required', 'string', 'max:255'],
+6 -1
View File
@@ -28,6 +28,9 @@ class StoreTicketLinkRequest extends FormRequest
{ {
/** @var Project $project */ /** @var Project $project */
$project = $this->route('project'); $project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [ return [
'linked_ticket_id' => [ 'linked_ticket_id' => [
@@ -38,7 +41,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($ownershipColumn, $ownershipValue),
),
], ],
]; ];
} }
@@ -26,12 +26,17 @@ class StoreTicketStatusRequest extends FormRequest
{ {
/** @var Project $project */ /** @var Project $project */
$project = $this->route('project'); $project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [ return [
'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($ownershipColumn, $ownershipValue),
),
], ],
'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::copyableAreas())],
'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.',
];
}
}
@@ -51,6 +51,9 @@ class UpdateProjectTicketRequest extends FormRequest
{ {
/** @var Project $project */ /** @var Project $project */
$project = $this->route('project'); $project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [ return [
'acceptance_criteria' => ['nullable', 'array'], 'acceptance_criteria' => ['nullable', 'array'],
@@ -68,7 +71,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($ownershipColumn, $ownershipValue),
),
], ],
'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 +86,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($ownershipColumn, $ownershipValue),
),
], ],
'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($ownershipColumn, $ownershipValue),
),
], ],
'title' => ['required', 'string', 'max:255'], 'title' => ['required', 'string', 'max:255'],
'story_points' => ['nullable', 'integer', 'min:0'], 'story_points' => ['nullable', 'integer', 'min:0'],
@@ -141,7 +150,7 @@ class UpdateProjectTicketRequest extends FormRequest
$requestedTicketTypeId = Arr::get($validated, 'ticket_type_id'); $requestedTicketTypeId = Arr::get($validated, 'ticket_type_id');
$requestedTicketType = TicketType::query() $requestedTicketType = TicketType::query()
->where('project_id', $project->id) ->where($project->project_template_id === null ? 'project_id' : 'project_template_id', $project->project_template_id ?? $project->id)
->find($requestedTicketTypeId); ->find($requestedTicketTypeId);
if ( if (
@@ -29,12 +29,17 @@ class UpdateTicketStatusTransitionsRequest extends FormRequest
{ {
/** @var Project $project */ /** @var Project $project */
$project = $this->route('project'); $project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [ return [
'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($ownershipColumn, $ownershipValue),
),
], ],
]; ];
} }
@@ -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,21 @@ class ValidateProjectTicketBranchRequest extends FormRequest
*/ */
public function rules(): array public function rules(): array
{ {
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
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($ownershipColumn, $ownershipValue),
),
],
]; ];
} }
} }
+24
View File
@@ -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,32 @@ 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', []),
'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,53 @@
<?php
namespace App\Http\Resources;
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) {
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' => $this->copy_areas ?? [],
'configuration_snapshot' => $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'),
];
}
}
+10 -1
View File
@@ -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,11 +11,19 @@ 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
{ {
@@ -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);
}
}
+10
View File
@@ -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,12 +24,14 @@ 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
{ {
/** @use HasFactory<NotificationActionFactory> */ /** @use HasFactory<NotificationActionFactory> */
use HasFactory; use HasFactory;
use SyncsProjectTemplateOwnership;
/** /**
* @return array<string, string> * @return array<string, string>
@@ -45,6 +48,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);
+14 -1
View File
@@ -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'])]
class Project extends Model class Project extends Model
{ {
/** @use HasFactory<ProjectFactory> */ /** @use HasFactory<ProjectFactory> */
@@ -44,6 +46,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)
+47
View File
@@ -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');
}
}
+10
View File
@@ -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',
@@ -24,6 +26,7 @@ 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 +38,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);
+10 -1
View File
@@ -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
{ {
/** /**
@@ -22,6 +23,14 @@ 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
{ {
+10 -1
View File
@@ -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,11 +10,19 @@ 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
{ {
+10 -1
View File
@@ -2,17 +2,26 @@
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;
#[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
{ {
+10 -1
View File
@@ -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,23 @@ 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);
}); });
} }
+10 -1
View File
@@ -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,11 +10,19 @@ 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
{ {
+3 -19
View File
@@ -77,28 +77,12 @@ class User extends Authenticatable
protected function avatar(): Attribute protected function avatar(): Attribute
{ {
return Attribute::get( return Attribute::get(
fn (): ?string => $this->avatarUrl(), fn (): ?string => filled($this->avatar_path)
? 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');
-1
View File
@@ -9,7 +9,6 @@
"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"
-2
View File
@@ -17,8 +17,6 @@ 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: [
+3 -4
View File
@@ -12,11 +12,10 @@
"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": "^v13.26.1", "laravel/framework": "^13.0",
"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": {
@@ -52,7 +51,7 @@
], ],
"dev": [ "dev": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"@php artisan dev" "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"
], ],
"dev:ssr": [ "dev:ssr": [
"npm run build:ssr", "npm run build:ssr",
@@ -67,8 +66,8 @@
], ],
"ci:check": [ "ci:check": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"npm audit --audit-level=high",
"npm run lint:check", "npm run lint:check",
"npm run format:check",
"npm run types:check", "npm run types:check",
"@test" "@test"
], ],
Generated
+332 -791
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -15,12 +15,6 @@ 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
@@ -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,116 @@
<?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',
])
->get();
foreach ($projects as $project) {
$projectTemplateId = $project->project_template_id;
if ($projectTemplateId === null || (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) {
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');
});
}
}
};
+12 -32
View File
@@ -1,38 +1,33 @@
services: services:
web: web:
container_name: kanbangit-web container_name: kanbangit-web
image: kanbangit-app:local
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: ./docker/nginx/Dockerfile
target: runtime
command: nginx
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8080:8080" - "8080:80"
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: Dockerfile dockerfile: ./docker/php-fpm/Dockerfile
target: runtime target: production
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:
@@ -47,23 +42,6 @@ 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
@@ -87,3 +65,5 @@ networks:
volumes: volumes:
mariadb-data-production: mariadb-data-production:
kanbangit-storage:
kanbangit-public-assets:
-26
View File
@@ -1,26 +0,0 @@
#!/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 "$@"
+20
View File
@@ -0,0 +1,20 @@
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;"]
+33
View File
@@ -0,0 +1,33 @@
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;
}
}
}
-58
View File
@@ -1,58 +0,0 @@
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;
}
}
}
-7
View File
@@ -1,7 +0,0 @@
#!/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:'
+66
View File
@@ -0,0 +1,66 @@
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"]
+11
View File
@@ -0,0 +1,11 @@
#!/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 "$@"
-3
View File
@@ -37,10 +37,7 @@ 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/**',
], ],
}, },
+198 -497
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -19,10 +19,7 @@
</source> </source>
<php> <php>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:11ujGYe5vzbwqcdFDVQC6PuDgd7c8PKD9kWt9HH9Bvs="/>
<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"/>
+10
View File
@@ -5,6 +5,7 @@ import {
Folder, Folder,
FolderGit2, FolderGit2,
LayoutGrid, LayoutGrid,
Layers3,
Shield, Shield,
Users, Users,
} from 'lucide-vue-next'; } from 'lucide-vue-next';
@@ -24,6 +25,7 @@ import {
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
import { index as adminUsers } from '@/routes/admin/users'; import { index as adminUsers } from '@/routes/admin/users';
import { index as projectTemplatesIndex } from '@/routes/project-templates';
import { index as projectsIndex } from '@/routes/projects'; import { index as projectsIndex } from '@/routes/projects';
import { index as teamsIndex } from '@/routes/teams'; import { index as teamsIndex } from '@/routes/teams';
import type { NavItem } from '@/types'; import type { NavItem } from '@/types';
@@ -44,6 +46,14 @@ const mainNavItems = computed<NavItem[]>(() => {
}, },
]; ];
if (page.props.auth.permissions.projectCreate) {
items.push({
title: 'Project templates',
href: projectTemplatesIndex(),
icon: Layers3,
});
}
if (page.props.auth.permissions.globalAdmin) { if (page.props.auth.permissions.globalAdmin) {
items.push({ items.push({
title: 'Users', title: 'Users',
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import { computed } from 'vue';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as projectsIndex, show as projectShow } from '@/routes/projects/index';
import {
create as projectTemplateCreate,
store as projectTemplateStore,
} from '@/routes/projects/templates';
import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types';
const props = defineProps<ProjectTemplatesCreatePageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: projectsIndex(),
},
{
title: props.project.name,
href: projectShow(props.project.id),
},
{
title: 'Save as new template',
href: projectTemplateCreate(props.project.id),
},
];
const form = useForm({
copy_areas: props.copyAreaOptions.map((copyAreaOption) => copyAreaOption.value),
name: `${props.project.name} template`,
slug: `${props.project.key.toLowerCase()}-template`,
});
const copyAreasError = computed(() => {
return (
form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) => key.startsWith('copy_areas.'))?.[1] ??
''
);
});
const submit = (): void => {
form.post(projectTemplateStore.url(props.project.id), {
preserveScroll: true,
});
};
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`Save ${project.name} as new template`" />
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
<div class="space-y-3">
<Badge variant="outline" class="px-3 py-1">{{ project.key }}</Badge>
<Heading
:title="`Save ${project.name} as new template`"
description="Capture reusable project configuration without copying operational ticket data."
/>
</div>
<Card>
<CardHeader>
<CardTitle>Template details</CardTitle>
<CardDescription>
Pick the configuration groups that should be reusable in future projects.
</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-6" @submit.prevent="submit">
<div class="grid gap-2">
<Label for="name">Template name</Label>
<Input id="name" v-model="form.name" required placeholder="Foundation template" />
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="slug">Template slug</Label>
<Input id="slug" v-model="form.slug" required placeholder="foundation-template" />
<p class="text-muted-foreground text-sm">
Slugs are normalized automatically and used to identify reusable templates.
</p>
<InputError :message="form.errors.slug" />
</div>
<div class="grid gap-3">
<div>
<Label>Configuration groups</Label>
<p class="text-muted-foreground text-sm">
Only the selected groups will be stored in the template.
</p>
</div>
<div class="grid gap-3">
<Label
v-for="copyAreaOption in copyAreaOptions"
:key="copyAreaOption.value"
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
>
<input
v-model="form.copy_areas"
type="checkbox"
:value="copyAreaOption.value"
class="mt-1"
/>
<div class="space-y-1">
<div class="font-medium">{{ copyAreaOption.label }}</div>
<p class="text-muted-foreground text-sm">
{{ copyAreaOption.description }}
</p>
</div>
</Label>
</div>
<InputError :message="copyAreasError" />
</div>
<div class="flex items-center gap-3">
<Button type="submit" :disabled="form.processing">
Save new template
</Button>
<Button as-child variant="outline">
<Link :href="projectShow(project.id)">Cancel</Link>
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout>
</template>
@@ -0,0 +1,175 @@
<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import { computed } from 'vue';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import {
edit as projectTemplatesEdit,
index as projectTemplatesIndex,
update as projectTemplatesUpdate,
} from '@/routes/project-templates';
import type { BreadcrumbItem, ProjectTemplatesEditPageProps } from '@/types';
const props = defineProps<ProjectTemplatesEditPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Project templates',
href: projectTemplatesIndex(),
},
{
title: props.template.name,
href: projectTemplatesEdit(props.template.id),
},
];
const form = useForm({
confirm_linked_projects_update: false,
copy_areas: [...props.template.copy_areas],
name: props.template.name,
});
const copyAreasError = computed(() => {
return (
form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) => key.startsWith('copy_areas.'))?.[1] ??
''
);
});
const linkedProjectCount = computed(() => props.template.linked_project_count ?? 0);
const submit = (): void => {
form.patch(projectTemplatesUpdate.url(props.template.id), {
preserveScroll: true,
});
};
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`Edit ${template.name}`" />
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-3">
<Badge variant="outline" class="px-3 py-1">{{ template.slug }}</Badge>
<Badge v-if="linkedProjectCount > 0" variant="secondary">
{{ linkedProjectCount }} linked {{ linkedProjectCount === 1 ? 'project' : 'projects' }}
</Badge>
</div>
<Heading
:title="`Edit ${template.name}`"
description="Refresh the template baseline from its source project while keeping immutable identifiers protected."
/>
</div>
<Card>
<CardHeader>
<CardTitle>Template details</CardTitle>
<CardDescription>
<template v-if="template.source_project">
This template baseline is rebuilt from {{ template.source_project.name }} ({{ template.source_project.key }}) when you save.
</template>
<template v-else>
This template baseline is rebuilt from its source project when you save.
</template>
</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-6" @submit.prevent="submit">
<div class="grid gap-2">
<Label for="name">Template name</Label>
<Input id="name" v-model="form.name" required placeholder="Foundation template" />
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="slug">Template slug</Label>
<Input id="slug" :model-value="template.slug" disabled />
<p class="text-muted-foreground text-sm">
The slug stays locked so existing template references remain stable.
</p>
</div>
<div class="grid gap-3">
<div>
<Label>Configuration groups</Label>
<p class="text-muted-foreground text-sm">
Saving rebuilds the selected groups from the current source-project configuration.
</p>
</div>
<div class="grid gap-3">
<Label
v-for="copyAreaOption in copyAreaOptions"
:key="copyAreaOption.value"
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
>
<input
v-model="form.copy_areas"
type="checkbox"
:value="copyAreaOption.value"
class="mt-1"
/>
<div class="space-y-1">
<div class="font-medium">{{ copyAreaOption.label }}</div>
<p class="text-muted-foreground text-sm">
{{ copyAreaOption.description }}
</p>
</div>
</Label>
</div>
<InputError :message="copyAreasError" />
</div>
<div
v-if="linkedProjectCount > 0"
class="space-y-3 rounded-xl border border-amber-300 bg-amber-50 p-4 text-amber-950 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
>
<div class="space-y-1">
<div class="font-medium">Warning</div>
<p class="text-sm">
Saving this template updates the baseline for {{ linkedProjectCount }} linked {{ linkedProjectCount === 1 ? 'project' : 'projects' }}.
Inherited areas will follow the new template version, while existing overrides stay untouched.
</p>
</div>
<Label class="flex cursor-pointer items-start gap-3">
<input v-model="form.confirm_linked_projects_update" type="checkbox" class="mt-1" />
<span class="text-sm">
I understand that linked projects inheriting from this template will be affected.
</span>
</Label>
<InputError :message="form.errors.confirm_linked_projects_update" />
</div>
<div class="flex items-center gap-3">
<Button type="submit" :disabled="form.processing">
Save template changes
</Button>
<Button as-child variant="outline">
<Link :href="projectTemplatesIndex()">Cancel</Link>
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout>
</template>
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import {
destroy as projectTemplatesDestroy,
edit as projectTemplatesEdit,
index as projectTemplatesIndex,
} from '@/routes/project-templates';
import { create as projectsCreate, index as projectsIndex } from '@/routes/projects/index';
import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types';
const props = defineProps<ProjectTemplatesIndexPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Project templates',
href: projectTemplatesIndex(),
},
];
const removeTemplate = (templateId: number): void => {
router.delete(projectTemplatesDestroy.url(templateId), {
preserveScroll: true,
});
};
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Project templates" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<Heading
title="Project templates"
description="Reusable published project templates. Internal project base templates stay hidden here by default."
/>
<div class="flex flex-wrap gap-3">
<Button as-child variant="outline">
<Link :href="projectsIndex()">Back to projects</Link>
</Button>
<Button as-child>
<Link :href="projectsCreate()">Create project</Link>
</Button>
</div>
</div>
<div
v-if="status"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
>
{{ status }}
</div>
<div v-if="templates.length === 0" class="text-muted-foreground text-sm">
No published project templates have been saved yet.
</div>
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card v-for="template in templates" :key="template.id" class="h-full">
<CardHeader>
<div class="flex flex-wrap items-center gap-2">
<CardTitle>{{ template.name }}</CardTitle>
<Badge variant="secondary">{{ template.slug }}</Badge>
<Badge v-if="template.linked_project_count" variant="outline">
{{ template.linked_project_count }} linked {{ template.linked_project_count === 1 ? 'project' : 'projects' }}
</Badge>
</div>
<CardDescription>
<template v-if="template.source_project">
Saved from {{ template.source_project.name }} ({{ template.source_project.key }}).
</template>
<template v-else>
Saved template configuration.
</template>
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex flex-wrap gap-2">
<Badge
v-for="copyArea in template.copy_areas"
:key="copyArea"
variant="outline"
>
{{ copyArea }}
</Badge>
</div>
<div class="flex flex-wrap gap-3">
<Button as-child variant="secondary">
<Link :href="projectTemplatesEdit(template.id)">
Edit
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="projectsCreate({ query: { template: template.id } })">
Use template
</Link>
</Button>
<Button
type="button"
variant="destructive"
@click="removeTemplate(template.id)"
>
Delete
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</AppLayout>
</template>
+206 -16
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3'; import { Head, Link, useForm } from '@inertiajs/vue3';
import { computed } from 'vue';
import ProjectController from '@/actions/App/Http/Controllers/ProjectController'; import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
@@ -15,7 +16,9 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { create, index } from '@/routes/projects/index'; import { create, index } from '@/routes/projects/index';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem, ProjectsCreatePageProps } from '@/types';
const props = defineProps<ProjectsCreatePageProps>();
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs: BreadcrumbItem[] = [
{ {
@@ -27,6 +30,43 @@ const breadcrumbs: BreadcrumbItem[] = [
href: create(), href: create(),
}, },
]; ];
const defaultTemplateId = props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
const form = useForm({
copy_areas: [] as string[],
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
key: '',
name: '',
source_project_id: props.sourceProjects[0]?.id ? String(props.sourceProjects[0].id) : '',
source_template_id: defaultTemplateId !== null ? String(defaultTemplateId) : '',
});
const hasSourceProjects = computed(() => props.sourceProjects.length > 0);
const hasSourceTemplates = computed(() => props.sourceTemplates.length > 0);
const copyAreasError = computed(() => {
return (
form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) => key.startsWith('copy_areas.'))?.[1] ??
''
);
});
const submit = (): void => {
if (form.creation_mode !== 'duplicate') {
form.source_project_id = '';
form.copy_areas = [];
}
if (form.creation_mode !== 'template') {
form.source_template_id = '';
}
form.post(ProjectController.store.url(), {
preserveScroll: true,
});
};
</script> </script>
<template> <template>
@@ -36,7 +76,7 @@ const breadcrumbs: BreadcrumbItem[] = [
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4"> <div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
<Heading <Heading
title="Create project" title="Create project"
description="Create a project with a stable uppercase key that will prefix all future ticket keys." description="Create a fresh project, duplicate selected settings from an existing project, or start from a saved template."
/> />
<Card> <Card>
@@ -49,28 +89,82 @@ const breadcrumbs: BreadcrumbItem[] = [
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Form <form class="space-y-6" @submit.prevent="submit">
v-bind="ProjectController.store.form()" <div class="grid gap-3">
class="space-y-6" <Label>How do you want to start?</Label>
v-slot="{ errors, processing }" <div class="grid gap-3 sm:grid-cols-3">
<Label class="flex cursor-pointer items-start gap-3 rounded-xl border p-4">
<input
v-model="form.creation_mode"
type="radio"
value="scratch"
class="mt-1"
/>
<div class="space-y-1">
<div class="font-medium">Create from scratch</div>
<p class="text-muted-foreground text-sm">
Start with the default project setup.
</p>
</div>
</Label>
<Label
class="flex items-start gap-3 rounded-xl border p-4"
:class="hasSourceProjects ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
> >
<input
v-model="form.creation_mode"
type="radio"
value="duplicate"
class="mt-1"
:disabled="!hasSourceProjects"
/>
<div class="space-y-1">
<div class="font-medium">Create from existing project</div>
<p class="text-muted-foreground text-sm">
Copy only the settings you explicitly select. Tickets are never duplicated.
</p>
</div>
</Label>
<Label
class="flex items-start gap-3 rounded-xl border p-4"
:class="hasSourceTemplates ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
>
<input
v-model="form.creation_mode"
type="radio"
value="template"
class="mt-1"
:disabled="!hasSourceTemplates"
/>
<div class="space-y-1">
<div class="font-medium">Create from template</div>
<p class="text-muted-foreground text-sm">
Reuse a saved configuration template.
</p>
</div>
</Label>
</div>
</div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="name">Project name</Label> <Label for="name">Project name</Label>
<Input <Input
id="name" id="name"
name="name" v-model="form.name"
required required
autofocus autofocus
placeholder="Kanban foundation" placeholder="Kanban foundation"
/> />
<InputError :message="errors.name" /> <InputError :message="form.errors.name" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="key">Project key</Label> <Label for="key">Project key</Label>
<Input <Input
id="key" id="key"
name="key" v-model="form.key"
required required
maxlength="5" maxlength="5"
placeholder="CORE" placeholder="CORE"
@@ -78,15 +172,111 @@ const breadcrumbs: BreadcrumbItem[] = [
/> />
<p class="text-muted-foreground text-sm"> <p class="text-muted-foreground text-sm">
Example ticket keys will look like Example ticket keys will look like
<span class="text-foreground font-medium" <span class="text-foreground font-medium">CORE-1</span>.
>CORE-1</span
>.
</p> </p>
<InputError :message="errors.key" /> <InputError :message="form.errors.key" />
</div>
<div
v-if="form.creation_mode === 'duplicate'"
class="space-y-6 rounded-xl border border-dashed p-4"
>
<div class="grid gap-2">
<Label for="source_project_id">Source project</Label>
<select
id="source_project_id"
v-model="form.source_project_id"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!hasSourceProjects"
>
<option value="">Select a source project</option>
<option
v-for="sourceProject in sourceProjects"
:key="sourceProject.id"
:value="String(sourceProject.id)"
>
{{ sourceProject.name }} ({{ sourceProject.key }})
</option>
</select>
<p class="text-muted-foreground text-sm">
The new project keeps its own identity, key, and future ticket numbering.
</p>
<InputError :message="form.errors.source_project_id" />
</div>
<div class="grid gap-3">
<div>
<Label>Copy options</Label>
<p class="text-muted-foreground text-sm">
Choose which settings should be copied into the new project.
</p>
</div>
<div class="grid gap-3">
<Label
v-for="copyAreaOption in copyAreaOptions"
:key="copyAreaOption.value"
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
>
<input
v-model="form.copy_areas"
type="checkbox"
:value="copyAreaOption.value"
class="mt-1"
/>
<div class="space-y-1">
<div class="font-medium">{{ copyAreaOption.label }}</div>
<p class="text-muted-foreground text-sm">
{{ copyAreaOption.description }}
</p>
</div>
</Label>
</div>
<p class="text-muted-foreground text-sm">
Operational ticket data is always left behind so the new project starts clean.
</p>
<InputError :message="copyAreasError" />
</div>
</div>
<div
v-else-if="form.creation_mode === 'template'"
class="space-y-4 rounded-xl border border-dashed p-4"
>
<div class="grid gap-2">
<Label for="source_template_id">Template</Label>
<select
id="source_template_id"
v-model="form.source_template_id"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!hasSourceTemplates"
>
<option value="">Select a template</option>
<option
v-for="sourceTemplate in sourceTemplates"
:key="sourceTemplate.id"
:value="String(sourceTemplate.id)"
>
{{ sourceTemplate.name }} ({{ sourceTemplate.slug }})
</option>
</select>
<p class="text-muted-foreground text-sm">
Templates apply their saved configuration groups to the new project.
</p>
<InputError :message="form.errors.source_template_id" />
</div>
</div>
<div
v-else-if="!hasSourceProjects && !hasSourceTemplates"
class="text-muted-foreground text-sm"
>
No accessible source projects or saved templates are available yet.
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<Button type="submit" :disabled="processing"> <Button type="submit" :disabled="form.processing">
Create project Create project
</Button> </Button>
@@ -94,7 +284,7 @@ const breadcrumbs: BreadcrumbItem[] = [
<Link :href="index()">Cancel</Link> <Link :href="index()">Cancel</Link>
</Button> </Button>
</div> </div>
</Form> </form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+37
View File
@@ -22,6 +22,7 @@ import {
notifications as notificationSettings, notifications as notificationSettings,
repositories as repositorySettings, repositories as repositorySettings,
} from '@/routes/projects/settings'; } from '@/routes/projects/settings';
import { create as projectTemplateCreate } from '@/routes/projects/templates';
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types'; import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
const props = defineProps<ProjectShowPageProps>(); const props = defineProps<ProjectShowPageProps>();
@@ -94,12 +95,48 @@ const projectAreas = [
</Link> </Link>
</Button> </Button>
<Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
variant="outline"
>
<Link :href="projectTemplateCreate(project.id)">
Save as new template
</Link>
</Button>
<Button as-child variant="outline"> <Button as-child variant="outline">
<Link :href="index()">Back to projects</Link> <Link :href="index()">Back to projects</Link>
</Button> </Button>
</div> </div>
</div> </div>
<Card v-if="templateState?.inheritsFromTemplate">
<CardHeader>
<CardTitle>
Built from template {{ templateState.templateName }}
</CardTitle>
<CardDescription>
This project keeps its template link. Inherited areas follow the template baseline; overridden areas were customized locally.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Badge
v-for="area in templateState.inheritedAreas"
:key="`inherited-${area.value}`"
variant="outline"
>
{{ area.label }} · inherited
</Badge>
<Badge
v-for="area in templateState.overriddenAreas"
:key="`overridden-${area.value}`"
>
{{ area.label }} · overridden
</Badge>
</CardContent>
</Card>
<div v-if="canManageAccess" class="flex flex-wrap gap-3"> <div v-if="canManageAccess" class="flex flex-wrap gap-3">
<Button as-child> <Button as-child>
<Link :href="boardSettings(project.id)"> <Link :href="boardSettings(project.id)">
@@ -138,6 +138,32 @@ const statusAllowsTarget = (
{{ status }} {{ status }}
</div> </div>
<Card v-if="templateState?.inheritsFromTemplate">
<CardHeader>
<CardTitle>
Template inheritance: {{ templateState.templateName }}
</CardTitle>
<CardDescription>
This project stays linked to its template. Sections below show whether the current configuration is still inherited or has local overrides.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Badge
v-for="area in templateState.inheritedAreas"
:key="`inherited-${area.value}`"
variant="outline"
>
{{ area.label }} · inherited
</Badge>
<Badge
v-for="area in templateState.overriddenAreas"
:key="`overridden-${area.value}`"
>
{{ area.label }} · overridden
</Badge>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Workflow model</CardTitle> <CardTitle>Workflow model</CardTitle>
@@ -43,20 +43,73 @@ const breadcrumbs: BreadcrumbItem[] = [
}, },
]; ];
const switchTemplateForm = useForm({
confirmation_key: '',
source_template_id: props.sourceTemplates.find(
(template) => template.id !== props.project.project_template_id,
)?.id,
});
const resetTemplateForm = useForm({
confirmation_key: '',
});
const deleteForm = useForm({ const deleteForm = useForm({
confirmation_key: '', confirmation_key: '',
}); });
const availableSwitchTemplates = computed(() =>
props.sourceTemplates.filter(
(template) => template.id !== props.project.project_template_id,
),
);
const currentTemplate = computed(() => props.project.project_template ?? null);
const isSwitchConfirmationValid = computed(
() => switchTemplateForm.confirmation_key === props.project.key,
);
const canSwitchTemplate = computed(
() =>
isSwitchConfirmationValid.value &&
switchTemplateForm.source_template_id !== undefined,
);
const isResetConfirmationValid = computed(
() => resetTemplateForm.confirmation_key === props.project.key,
);
const isDeleteConfirmationValid = computed( const isDeleteConfirmationValid = computed(
() => deleteForm.confirmation_key === props.project.key, () => deleteForm.confirmation_key === props.project.key,
); );
const submitSwitchTemplate = (): void => {
if (!canSwitchTemplate.value) {
return;
}
switchTemplateForm.post(ProjectController.switchTemplate.url(props.project.id), {
preserveScroll: true,
});
};
const submitResetTemplate = (): void => {
if (!isResetConfirmationValid.value) {
return;
}
resetTemplateForm.post(ProjectController.resetTemplate.url(props.project.id), {
preserveScroll: true,
});
};
const submitDelete = (): void => { const submitDelete = (): void => {
if (!isDeleteConfirmationValid.value) { if (!isDeleteConfirmationValid.value) {
return; return;
} }
deleteForm.submit(ProjectController.destroy(props.project.id), { deleteForm.delete(ProjectController.destroy.url(props.project.id), {
preserveScroll: true, preserveScroll: true,
}); });
}; };
@@ -152,6 +205,139 @@ const submitDelete = (): void => {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>Template baseline</CardTitle>
<CardDescription>
Switch this project to another published template or reset it back to the currently linked template baseline. Both actions can overwrite local configuration.
</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div class="grid gap-4 md:grid-cols-2">
<div class="border-border/70 rounded-xl border p-4">
<p class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]">
Current linked template
</p>
<p class="text-foreground mt-2 text-sm font-semibold">
{{ currentTemplate?.name ?? 'No linked template' }}
</p>
<p class="text-muted-foreground mt-2 text-sm">
{{
templateState?.inheritsFromTemplate
? 'Inherited and overridden areas stay visible after switching or resetting.'
: 'This project currently behaves like a standalone baseline.'
}}
</p>
</div>
<div class="border-border/70 rounded-xl border p-4">
<p class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]">
Warning
</p>
<p class="text-foreground mt-2 text-sm font-medium">
Template changes can replace board workflow, ticket metadata, repositories, and notifications for copied areas.
</p>
<p class="text-muted-foreground mt-2 text-sm">
Confirm each action with the exact project key to avoid accidental overwrites.
</p>
</div>
</div>
<div class="grid gap-6 xl:grid-cols-2">
<form class="space-y-4 rounded-xl border p-4" @submit.prevent="submitSwitchTemplate">
<div class="space-y-1">
<h3 class="text-sm font-semibold">Switch to another template</h3>
<p class="text-muted-foreground text-sm">
Applying a different template replaces the copied areas with that template's current baseline.
</p>
</div>
<div class="grid gap-2">
<Label for="source_template_id">Published template</Label>
<select
id="source_template_id"
v-model="switchTemplateForm.source_template_id"
:disabled="switchTemplateForm.processing || availableSwitchTemplates.length === 0"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option :value="undefined" disabled>
Select a template
</option>
<option
v-for="template in availableSwitchTemplates"
:key="template.id"
:value="template.id"
>
{{ template.name }}
</option>
</select>
<InputError :message="switchTemplateForm.errors.source_template_id" />
</div>
<div class="grid gap-2">
<Label for="switch_confirmation_key">Confirmation key</Label>
<Input
id="switch_confirmation_key"
v-model="switchTemplateForm.confirmation_key"
:disabled="switchTemplateForm.processing"
placeholder="Type the project key exactly"
/>
<InputError :message="switchTemplateForm.errors.confirmation_key" />
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-muted-foreground text-sm">
Existing project-specific configuration in copied areas may be replaced or removed.
</p>
<Button
type="submit"
variant="outline"
:disabled="switchTemplateForm.processing || !canSwitchTemplate"
>
Switch template
</Button>
</div>
</form>
<form class="space-y-4 rounded-xl border p-4" @submit.prevent="submitResetTemplate">
<div class="space-y-1">
<h3 class="text-sm font-semibold">Reset to current template baseline</h3>
<p class="text-muted-foreground text-sm">
Re-apply the currently linked template and clear local divergence inside copied areas.
</p>
</div>
<div class="grid gap-2">
<Label for="reset_confirmation_key">Confirmation key</Label>
<Input
id="reset_confirmation_key"
v-model="resetTemplateForm.confirmation_key"
:disabled="resetTemplateForm.processing || !currentTemplate"
placeholder="Type the project key exactly"
/>
<InputError :message="resetTemplateForm.errors.confirmation_key" />
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-muted-foreground text-sm">
This restores the current template baseline for copied areas and rewinds project-level overrides there.
</p>
<Button
type="submit"
variant="outline"
:disabled="resetTemplateForm.processing || !isResetConfirmationValid || !currentTemplate"
>
Reset to baseline
</Button>
</div>
</form>
</div>
</CardContent>
</Card>
<Card <Card
class="border-red-200/80 bg-red-50/60 dark:border-red-900/60 dark:bg-red-950/20" class="border-red-200/80 bg-red-50/60 dark:border-red-900/60 dark:bg-red-950/20"
> >
@@ -115,6 +115,32 @@ const replacementTicketTypes = (ticketTypeId: number) =>
{{ status }} {{ status }}
</div> </div>
<Card v-if="templateState?.inheritsFromTemplate">
<CardHeader>
<CardTitle>
Template inheritance: {{ templateState.templateName }}
</CardTitle>
<CardDescription>
Inherited sections stay synced with the template baseline. Local edits in these settings are treated as project-specific overrides.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Badge
v-for="area in templateState.inheritedAreas"
:key="`inherited-${area.value}`"
variant="outline"
>
{{ area.label }} · inherited
</Badge>
<Badge
v-for="area in templateState.overriddenAreas"
:key="`overridden-${area.value}`"
>
{{ area.label }} · overridden
</Badge>
</CardContent>
</Card>
<div class="grid gap-6 xl:grid-cols-2"> <div class="grid gap-6 xl:grid-cols-2">
<Card class="gap-0 overflow-hidden"> <Card class="gap-0 overflow-hidden">
<CardHeader class="border-b"> <CardHeader class="border-b">
@@ -305,6 +305,32 @@ const notificationActionTimestamp = (
{{ status }} {{ status }}
</div> </div>
<Card v-if="templateState?.inheritsFromTemplate">
<CardHeader>
<CardTitle>
Template inheritance: {{ templateState.templateName }}
</CardTitle>
<CardDescription>
Notification rules can remain inherited from the template or diverge here as project-level overrides.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Badge
v-for="area in templateState.inheritedAreas"
:key="`inherited-${area.value}`"
variant="outline"
>
{{ area.label }} · inherited
</Badge>
<Badge
v-for="area in templateState.overriddenAreas"
:key="`overridden-${area.value}`"
>
{{ area.label }} · overridden
</Badge>
</CardContent>
</Card>
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]"> <div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
<div class="space-y-6"> <div class="space-y-6">
<Card> <Card>
@@ -277,6 +277,32 @@ const submitUpdate = (): void => {
{{ status }} {{ status }}
</div> </div>
<Card v-if="templateState?.inheritsFromTemplate">
<CardHeader>
<CardTitle>
Template inheritance: {{ templateState.templateName }}
</CardTitle>
<CardDescription>
Repository links can stay inherited from the template or be replaced with project-specific overrides.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Badge
v-for="area in templateState.inheritedAreas"
:key="`inherited-${area.value}`"
variant="outline"
>
{{ area.label }} · inherited
</Badge>
<Badge
v-for="area in templateState.overriddenAreas"
:key="`overridden-${area.value}`"
>
{{ area.label }} · overridden
</Badge>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Personal Git access</CardTitle> <CardTitle>Personal Git access</CardTitle>
@@ -1,7 +1,11 @@
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
export type ProjectModel = { export type ProjectModel = {
id: number; id: number;
name: string; name: string;
key: string; key: string;
project_template_id?: number | null;
project_template?: ProjectTemplateModel | null;
show_author: boolean; show_author: boolean;
show_story_points: boolean; show_story_points: boolean;
created_at: string; created_at: string;
@@ -0,0 +1,16 @@
import type { ProjectModel } from '@/types/models/project-model';
export type ProjectTemplateModel = {
id: number;
is_published: boolean;
project_id: number | null;
source_project_id: number | null;
source_project: ProjectModel | null;
name: string;
slug: string;
copy_areas: string[];
configuration_snapshot: Record<string, unknown>;
linked_project_count?: number;
created_at: string | null;
updated_at: string | null;
};
+54
View File
@@ -4,6 +4,7 @@ import type { NotificationActionModel } from '@/types/models/notification-action
import type { NotificationActionOptionModel } from '@/types/models/notification-action-option-model'; import type { NotificationActionOptionModel } from '@/types/models/notification-action-option-model';
import type { NotificationActionTemplateVariableModel } from '@/types/models/notification-action-template-variable-model'; import type { NotificationActionTemplateVariableModel } from '@/types/models/notification-action-template-variable-model';
import type { ProjectModel } from '@/types/models/project-model'; import type { ProjectModel } from '@/types/models/project-model';
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model'; import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
import type { TeamModel } from '@/types/models/team-model'; import type { TeamModel } from '@/types/models/team-model';
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model'; import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
@@ -32,14 +33,61 @@ export type AdminUserEditPageProps = {
user: UserModel; user: UserModel;
}; };
export type ProjectDuplicateCopyAreaOption = {
description: string;
label: string;
value: string;
};
export type ProjectsIndexPageProps = { export type ProjectsIndexPageProps = {
projects: ProjectModel[]; projects: ProjectModel[];
status?: string | null; status?: string | null;
}; };
export type ProjectsCreatePageProps = {
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
selectedSourceTemplateId?: number | null;
sourceProjects: ProjectModel[];
sourceTemplates: ProjectTemplateModel[];
};
export type ProjectTemplatesIndexPageProps = {
status?: string | null;
templates: ProjectTemplateModel[];
};
export type ProjectTemplatesCreatePageProps = {
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
project: ProjectModel;
};
export type ProjectTemplatesEditPageProps = {
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
template: ProjectTemplateModel;
};
export type ProjectTemplateStateArea = {
label: string;
value: string;
};
export type ProjectTemplateState = {
effectiveConfiguration: Record<string, unknown>;
inheritedAreas: ProjectTemplateStateArea[];
inheritedConfiguration?: Record<string, unknown>;
inheritsFromTemplate: boolean;
overriddenAreas: ProjectTemplateStateArea[];
overriddenConfiguration?: Record<string, unknown>;
templateConfiguration?: Record<string, unknown>;
templateId: number;
templateName: string;
templateSlug: string;
};
export type ProjectShowPageProps = { export type ProjectShowPageProps = {
canManageAccess: boolean; canManageAccess: boolean;
project: ProjectModel; project: ProjectModel;
templateState?: ProjectTemplateState | null;
}; };
export type PermissionOption = { export type PermissionOption = {
@@ -71,12 +119,15 @@ export type ProjectBoardSettingsPageProps = {
boardColumns: BoardColumnModel[]; boardColumns: BoardColumnModel[];
project: ProjectModel; project: ProjectModel;
status?: string | null; status?: string | null;
templateState?: ProjectTemplateState | null;
ticketStatuses: TicketStatusModel[]; ticketStatuses: TicketStatusModel[];
}; };
export type ProjectGeneralSettingsPageProps = { export type ProjectGeneralSettingsPageProps = {
project: ProjectModel; project: ProjectModel;
sourceTemplates: ProjectTemplateModel[];
status?: string | null; status?: string | null;
templateState?: ProjectTemplateState | null;
}; };
export type ProjectBoardPageProps = { export type ProjectBoardPageProps = {
@@ -97,6 +148,7 @@ export type ProjectTicketMetadataSettingsPageProps = {
project: ProjectModel; project: ProjectModel;
error?: boolean; error?: boolean;
status?: string | null; status?: string | null;
templateState?: ProjectTemplateState | null;
ticketFieldDefinitions: TicketFieldDefinitionModel[]; ticketFieldDefinitions: TicketFieldDefinitionModel[];
ticketLinkTypes: TicketLinkTypeModel[]; ticketLinkTypes: TicketLinkTypeModel[];
ticketPriorities: TicketPriorityModel[]; ticketPriorities: TicketPriorityModel[];
@@ -108,6 +160,7 @@ export type ProjectRepositorySettingsPageProps = {
project: ProjectModel; project: ProjectModel;
repositoryConnections: RepositoryConnectionModel[]; repositoryConnections: RepositoryConnectionModel[];
status?: string | null; status?: string | null;
templateState?: ProjectTemplateState | null;
}; };
export type ProjectNotificationSettingsPageProps = { export type ProjectNotificationSettingsPageProps = {
@@ -119,6 +172,7 @@ export type ProjectNotificationSettingsPageProps = {
notificationActions: NotificationActionModel[]; notificationActions: NotificationActionModel[];
project: ProjectModel; project: ProjectModel;
status?: string | null; status?: string | null;
templateState?: ProjectTemplateState | null;
templateVariables: NotificationActionTemplateVariableModel[]; templateVariables: NotificationActionTemplateVariableModel[];
}; };
+1
View File
@@ -20,6 +20,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::inertia('dashboard', 'Dashboard')->name('dashboard'); Route::inertia('dashboard', 'Dashboard')->name('dashboard');
require __DIR__.'/web/admin.php'; require __DIR__.'/web/admin.php';
require __DIR__.'/web/project-templates.php';
require __DIR__.'/web/projects.php'; require __DIR__.'/web/projects.php';
require __DIR__.'/web/teams.php'; require __DIR__.'/web/teams.php';
}); });
+14
View File
@@ -0,0 +1,14 @@
<?php
use App\Http\Controllers\ProjectTemplateController;
use Illuminate\Support\Facades\Route;
Route::middleware('can:create-project')
->prefix('project-templates')
->name('project-templates.')
->group(function (): void {
Route::get('/', [ProjectTemplateController::class, 'index'])->name('index');
Route::get('{projectTemplate}/edit', [ProjectTemplateController::class, 'edit'])->name('edit');
Route::patch('{projectTemplate}', [ProjectTemplateController::class, 'update'])->name('update');
Route::delete('{projectTemplate}', [ProjectTemplateController::class, 'destroy'])->name('destroy');
});
+2
View File
@@ -8,6 +8,8 @@ use App\Http\Controllers\ProjectTicketMetadataSettingsController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('general', [ProjectController::class, 'general'])->name('general'); Route::get('general', [ProjectController::class, 'general'])->name('general');
Route::post('general/switch-template', [ProjectController::class, 'switchTemplate'])->name('general.switch-template');
Route::post('general/reset-template', [ProjectController::class, 'resetTemplate'])->name('general.reset-template');
Route::get('board', [ProjectBoardSettingsController::class, 'show'])->name('board'); Route::get('board', [ProjectBoardSettingsController::class, 'show'])->name('board');
+6
View File
@@ -2,6 +2,7 @@
use App\Http\Controllers\ProjectBoardController; use App\Http\Controllers\ProjectBoardController;
use App\Http\Controllers\ProjectController; use App\Http\Controllers\ProjectController;
use App\Http\Controllers\ProjectTemplateController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('projects')->name('projects.')->group(function (): void { Route::prefix('projects')->name('projects.')->group(function (): void {
@@ -18,6 +19,11 @@ Route::prefix('projects')->name('projects.')->group(function (): void {
Route::get('/', [ProjectController::class, 'show'])->name('show'); Route::get('/', [ProjectController::class, 'show'])->name('show');
Route::get('board', [ProjectBoardController::class, 'index'])->name('board.index'); Route::get('board', [ProjectBoardController::class, 'index'])->name('board.index');
Route::get('backlog', [ProjectController::class, 'backlog'])->name('backlog.index'); Route::get('backlog', [ProjectController::class, 'backlog'])->name('backlog.index');
Route::middleware('can:create-project')->group(function (): void {
Route::get('templates/create', [ProjectTemplateController::class, 'create'])->name('templates.create');
Route::post('templates', [ProjectTemplateController::class, 'store'])->name('templates.store');
});
}); });
Route::delete('/', [ProjectController::class, 'destroy']) Route::delete('/', [ProjectController::class, 'destroy'])
+216
View File
@@ -2,8 +2,13 @@
namespace Tests\Feature\Api; namespace Tests\Feature\Api;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\TicketStatus;
use App\Models\TicketType;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
@@ -38,6 +43,7 @@ class ProjectApiTest extends TestCase
$this->withApplicationKey($user) $this->withApplicationKey($user)
->postJson(route('api.projects.store'), [ ->postJson(route('api.projects.store'), [
'creation_mode' => 'scratch',
'key' => 'api', 'key' => 'api',
'name' => 'API Project', 'name' => 'API Project',
]) ])
@@ -47,8 +53,218 @@ class ProjectApiTest extends TestCase
->assertJsonPath('meta.message', 'API Project was created.'); ->assertJsonPath('meta.message', 'API Project was created.');
$project = Project::query()->where('key', 'API')->firstOrFail(); $project = Project::query()->where('key', 'API')->firstOrFail();
$project->refresh();
$this->assertSame(2, $project->ticketTypes()->count()); $this->assertSame(2, $project->ticketTypes()->count());
$this->assertSame(1, $project->ticketStatuses()->count()); $this->assertSame(1, $project->ticketStatuses()->count());
$this->assertNotNull($project->project_template_id);
}
public function test_users_can_create_projects_from_templates_via_api(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$template = ProjectTemplate::factory()->create([
'name' => 'API Template',
'slug' => 'api-template',
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
'configuration_snapshot' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => false,
'show_story_points' => false,
],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
[
'name' => 'Spike',
'position' => 1,
'is_parent' => false,
],
],
],
]);
$this->withApplicationKey($user)
->postJson(route('api.projects.store'), [
'key' => 'tpl1',
'name' => 'Template API Project',
'creation_mode' => 'template',
'source_template_id' => $template->id,
])
->assertCreated()
->assertJsonPath('data.key', 'TPL1')
->assertJsonPath('data.name', 'Template API Project');
$project = Project::query()->where('key', 'TPL1')->firstOrFail();
$project->refresh();
$this->assertSame($template->id, $project->project_template_id);
$this->assertDatabaseMissing('project_templates', [
'project_id' => $project->id,
'is_published' => false,
]);
$this->assertFalse($project->show_author);
$this->assertFalse($project->show_story_points);
$this->assertSame(['Spike'], $project->ticketTypes()->pluck('name')->all());
$this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id,
'project_template_id' => $template->id,
'name' => 'Spike',
]);
}
public function test_project_api_exposes_template_context_and_overrides(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'TMP1',
'name' => 'Template Driven Project',
'show_author' => false,
'show_story_points' => false,
]);
$template = ProjectTemplate::factory()->create([
'name' => 'Workflow Template',
'slug' => 'workflow-template',
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
'configuration_snapshot' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => true,
'show_story_points' => true,
],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
[
'name' => 'Bug',
'position' => 1,
'is_parent' => false,
],
[
'name' => 'Feature',
'position' => 2,
'is_parent' => false,
],
],
],
]);
$project->update([
'project_template_id' => $template->id,
]);
$project->ticketTypes()->delete();
TicketType::factory()->create([
'project_id' => $project->id,
'name' => 'Bug',
'position' => 1,
'is_parent' => false,
]);
TicketType::factory()->create([
'project_id' => $project->id,
'name' => 'Feature',
'position' => 2,
'is_parent' => false,
]);
$this->syncProjectPermissions($project, $user, ['view']);
$this->withApplicationKey($user)
->getJson(route('api.projects.show', $project))
->assertOk()
->assertJsonPath('data.project_template.id', $template->id)
->assertJsonPath('data.project_template.slug', 'workflow-template')
->assertJsonPath('data.project_template.configuration_snapshot.project_settings.show_author', true)
->assertJsonPath('data.template_state.inherits_from_template', true)
->assertJsonPath('data.template_state.template_id', $template->id)
->assertJsonPath('data.template_state.template_configuration.project_settings.show_author', true)
->assertJsonPath('data.template_state.effective_configuration.ticket_types.0.name', 'Bug')
->assertJsonPath('data.template_state.inherited_configuration.ticket_types.1.name', 'Feature')
->assertJsonPath('data.template_state.overridden_configuration.project_settings.show_author', false)
->assertJsonPath('data.template_state.overridden_areas.0.value', DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS);
}
public function test_users_can_duplicate_projects_via_api(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$sourceProject = Project::factory()->create([
'key' => 'SRC1',
'show_author' => false,
]);
$this->syncProjectPermissions($sourceProject, $user, ['view']);
$sourceProject->ticketTypes()->delete();
$sourceProject->ticketStatuses()->delete();
$sourceProject->boardColumns()->delete();
$column = BoardColumn::factory()->create([
'project_id' => $sourceProject->id,
'name' => 'In Progress',
'position' => 1,
]);
$backlogStatus = TicketStatus::factory()->create([
'project_id' => $sourceProject->id,
'name' => 'Queued',
'position' => 1,
]);
$activeStatus = TicketStatus::factory()->create([
'project_id' => $sourceProject->id,
'board_column_id' => $column->id,
'name' => 'Active',
'position' => 1,
]);
$backlogStatus->allowedTargetStatuses()->sync([$activeStatus->id]);
TicketType::factory()->create([
'project_id' => $sourceProject->id,
'name' => 'Spike',
'position' => 1,
]);
$this->withApplicationKey($user)
->postJson(route('api.projects.store'), [
'key' => 'dup2',
'name' => 'Duplicated API Project',
'creation_mode' => 'duplicate',
'source_project_id' => $sourceProject->id,
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
])
->assertCreated()
->assertJsonPath('data.key', 'DUP2')
->assertJsonPath('data.name', 'Duplicated API Project');
$project = Project::query()->where('key', 'DUP2')->firstOrFail();
$this->assertFalse($project->show_author);
$this->assertSame(['In Progress'], $project->boardColumns()->pluck('name')->all());
$this->assertSame(['Queued', 'Active'], $project->ticketStatuses()->pluck('name')->all());
$this->assertSame(['Spike'], $project->ticketTypes()->pluck('name')->all());
$this->assertSame(['Active'], $project->ticketStatuses()->where('name', 'Queued')->firstOrFail()->allowedTargetStatuses()->pluck('name')->all());
}
public function test_project_duplication_via_api_requires_visible_source_project(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$sourceProject = Project::factory()->create();
$this->withApplicationKey($user)
->postJson(route('api.projects.store'), [
'key' => 'nope',
'name' => 'No Access',
'creation_mode' => 'duplicate',
'source_project_id' => $sourceProject->id,
])
->assertStatus(422)
->assertJsonValidationErrors(['source_project_id']);
} }
public function test_project_show_and_delete_enforce_permissions(): void public function test_project_show_and_delete_enforce_permissions(): void
@@ -0,0 +1,146 @@
<?php
namespace Tests\Feature\Database;
use App\Actions\Projects\DuplicateProjectConfiguration;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class ProjectTemplateBaselineMigrationTest extends TestCase
{
public function test_migration_backfills_hidden_base_templates_for_existing_projects(): void
{
Artisan::call('migrate:fresh', [
'--path' => [
'database/migrations/0001_01_01_000000_create_users_table.php',
'database/migrations/0001_01_01_000001_create_cache_table.php',
'database/migrations/0001_01_01_000002_create_jobs_table.php',
'database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php',
'database/migrations/2026_03_24_234007_create_projects_table.php',
'database/migrations/2026_03_24_234734_create_teams_table.php',
'database/migrations/2026_03_24_234735_create_team_user_table.php',
'database/migrations/2026_03_25_002100_create_board_columns_table.php',
'database/migrations/2026_03_25_003100_create_ticket_types_table.php',
'database/migrations/2026_03_25_003101_create_ticket_priorities_table.php',
'database/migrations/2026_03_25_003102_create_ticket_field_definitions_table.php',
'database/migrations/2026_03_25_004098_create_ticket_statuses_table.php',
'database/migrations/2026_03_25_004099_create_ticket_statuses_transitionstable.php',
'database/migrations/2026_03_25_004100_create_tickets_table.php',
'database/migrations/2026_03_25_065302_create_ticket_worklog_entries_table.php',
'database/migrations/2026_03_25_070706_create_ticket_field_values_table.php',
'database/migrations/2026_03_25_073715_create_ticket_links_table.php',
'database/migrations/2026_03_25_073715_create_ticket_link_types_table.php',
'database/migrations/2026_03_26_074522_create_user_git_connections_table.php',
'database/migrations/2026_03_26_075849_create_repository_connections_table.php',
'database/migrations/2026_04_12_120000_create_permission_assignments_table.php',
'database/migrations/2026_04_20_174556_create_user_applications_table.php',
'database/migrations/2026_04_20_194646_create_notification_actions_table.php',
'database/migrations/2026_04_20_194646_create_notification_delivery_logs_table.php',
'database/migrations/2026_04_24_194800_add_filters_to_notification_actions_table.php',
'database/migrations/2026_04_24_203500_create_acceptance_criteria_table.php',
'database/migrations/2026_05_19_230500_drop_acceptance_criteria_from_tickets_table.php',
'database/migrations/2026_05_22_124700_add_must_change_password_on_next_login_to_users_table.php',
'database/migrations/2026_05_23_101900_add_counterpart_label_to_ticket_link_types_table.php',
'database/migrations/2026_05_25_011500_create_project_templates_table.php',
],
]);
DB::table('projects')->insert([
'name' => 'Legacy Project',
'key' => 'LGCY',
'show_author' => 0,
'show_story_points' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$projectId = (int) DB::table('projects')->where('key', 'LGCY')->value('id');
Artisan::call('migrate', [
'--path' => 'database/migrations/2026_05_25_013000_project_templates_as_project_baselines.php',
]);
$project = DB::table('projects')->where('id', $projectId)->first();
$template = DB::table('project_templates')->where('id', $project->project_template_id)->first();
$this->assertNotNull($project->project_template_id);
$this->assertNotNull($template);
$this->assertSame($projectId, $template->project_id);
$this->assertSame($projectId, $template->source_project_id);
$this->assertSame(0, $template->is_published);
$this->assertSame('lgcy-base-template', $template->slug);
}
public function test_metadata_migration_moves_existing_configuration_to_the_project_template_and_preserves_snapshot(): void
{
Artisan::call('migrate:fresh', [
'--path' => [
'database/migrations/0001_01_01_000000_create_users_table.php',
'database/migrations/0001_01_01_000001_create_cache_table.php',
'database/migrations/0001_01_01_000002_create_jobs_table.php',
'database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php',
'database/migrations/2026_03_24_234007_create_projects_table.php',
'database/migrations/2026_03_24_234734_create_teams_table.php',
'database/migrations/2026_03_24_234735_create_team_user_table.php',
'database/migrations/2026_03_25_002100_create_board_columns_table.php',
'database/migrations/2026_03_25_003100_create_ticket_types_table.php',
'database/migrations/2026_03_25_003101_create_ticket_priorities_table.php',
'database/migrations/2026_03_25_003102_create_ticket_field_definitions_table.php',
'database/migrations/2026_03_25_004098_create_ticket_statuses_table.php',
'database/migrations/2026_03_25_004099_create_ticket_statuses_transitionstable.php',
'database/migrations/2026_03_25_004100_create_tickets_table.php',
'database/migrations/2026_03_25_065302_create_ticket_worklog_entries_table.php',
'database/migrations/2026_03_25_070706_create_ticket_field_values_table.php',
'database/migrations/2026_03_25_073715_create_ticket_links_table.php',
'database/migrations/2026_03_25_073715_create_ticket_link_types_table.php',
'database/migrations/2026_03_26_074522_create_user_git_connections_table.php',
'database/migrations/2026_03_26_075849_create_repository_connections_table.php',
'database/migrations/2026_04_12_120000_create_permission_assignments_table.php',
'database/migrations/2026_04_20_174556_create_user_applications_table.php',
'database/migrations/2026_04_20_194646_create_notification_actions_table.php',
'database/migrations/2026_04_20_194646_create_notification_delivery_logs_table.php',
'database/migrations/2026_04_24_194800_add_filters_to_notification_actions_table.php',
'database/migrations/2026_04_24_203500_create_acceptance_criteria_table.php',
'database/migrations/2026_05_19_230500_drop_acceptance_criteria_from_tickets_table.php',
'database/migrations/2026_05_22_124700_add_must_change_password_on_next_login_to_users_table.php',
'database/migrations/2026_05_23_101900_add_counterpart_label_to_ticket_link_types_table.php',
'database/migrations/2026_05_25_011500_create_project_templates_table.php',
'database/migrations/2026_05_25_013000_project_templates_as_project_baselines.php',
],
]);
DB::table('projects')->insert([
'name' => 'Legacy Project',
'key' => 'LGCY',
'show_author' => 0,
'show_story_points' => 1,
'project_template_id' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$projectId = (int) DB::table('projects')->where('key', 'LGCY')->value('id');
DB::table('ticket_types')->insert([
'project_id' => $projectId,
'name' => 'Bug',
'position' => 1,
'is_parent' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
Artisan::call('migrate', [
'--path' => 'database/migrations/2026_06_05_161500_move_project_metadata_to_project_templates.php',
]);
$project = DB::table('projects')->where('id', $projectId)->first();
$template = DB::table('project_templates')->where('id', $project->project_template_id)->first();
$ticketType = DB::table('ticket_types')->where('project_id', $projectId)->first();
$snapshot = json_decode((string) $template->configuration_snapshot, true, 512, JSON_THROW_ON_ERROR);
$this->assertSame($project->project_template_id, $ticketType->project_template_id);
$this->assertSame(['Bug'], array_column($snapshot[DuplicateProjectConfiguration::COPY_TICKET_TYPES], 'name'));
}
}
+63
View File
@@ -4,6 +4,7 @@ namespace Tests\Feature\Mcp;
use App\Actions\Notifications\DeliverNotificationAction; use App\Actions\Notifications\DeliverNotificationAction;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Enums\NotificationDeliveryStatus; use App\Enums\NotificationDeliveryStatus;
use App\Mcp\Servers\KanbanGitServer; use App\Mcp\Servers\KanbanGitServer;
use App\Mcp\Tools\GetTicket; use App\Mcp\Tools\GetTicket;
@@ -15,8 +16,10 @@ use App\Mcp\Tools\UpdateTicketDescription;
use App\Models\BoardColumn; use App\Models\BoardColumn;
use App\Models\NotificationAction; use App\Models\NotificationAction;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketStatus; use App\Models\TicketStatus;
use App\Models\TicketType;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
@@ -207,6 +210,66 @@ class KanbanGitMcpTest extends TestCase
->etc()); ->etc());
} }
public function test_get_ticket_exposes_project_template_context_and_overrides(): void
{
$user = User::factory()->create();
$project = $this->project('CORE', 'Core project');
$template = ProjectTemplate::factory()->create([
'name' => 'Agent Workflow Template',
'slug' => 'agent-workflow-template',
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
'configuration_snapshot' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => true,
'show_story_points' => true,
],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
[
'name' => 'Bug',
'position' => 1,
'is_parent' => false,
],
],
],
]);
$project->update([
'project_template_id' => $template->id,
'show_author' => false,
'show_story_points' => false,
]);
$project->ticketTypes()->delete();
TicketType::factory()->create([
'project_id' => $project->id,
'name' => 'Bug',
'position' => 1,
'is_parent' => false,
]);
$status = $project->ticketStatuses()->firstOrFail();
$ticket = $this->ticket($project, $status, [
'ticket_key' => 'CORE-1',
'title' => 'Template aware ticket',
]);
$this->syncProjectPermissions($project, $user, ['view']);
KanbanGitServer::actingAs($user)
->tool(GetTicket::class, [
'ticket_id' => $ticket->id,
])
->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json
->where('data.project.project_template.id', $template->id)
->where('data.project.project_template.slug', 'agent-workflow-template')
->where('data.project.template_state.inherits_from_template', true)
->where('data.project.template_state.template_configuration.project_settings.show_author', true)
->where('data.project.template_state.overridden_configuration.project_settings.show_author', false)
->where('data.project.template_state.effective_configuration.ticket_types.0.name', 'Bug')
->etc());
}
public function test_description_update_requires_write_permission_and_preserves_unrelated_fields(): void public function test_description_update_requires_write_permission_and_preserves_unrelated_fields(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
+3 -36
View File
@@ -5,14 +5,11 @@ namespace Tests\Feature\Models;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
use Database\Seeders\UserSeeder; use Database\Seeders\UserSeeder;
use DateTimeInterface;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Mockery;
use Tests\TestCase; use Tests\TestCase;
class UserTest extends TestCase class UserTest extends TestCase
@@ -35,13 +32,9 @@ class UserTest extends TestCase
]); ]);
} }
public function test_user_avatar_attribute_returns_configured_storage_url(): void public function test_user_avatar_attribute_returns_public_storage_url(): void
{ {
config([ Storage::fake('public');
'filesystems.avatar_disk' => 'avatar-test',
'filesystems.avatar_urls_temporary' => false,
]);
Storage::fake('avatar-test');
$user = User::factory()->create([ $user = User::factory()->create([
'avatar_path' => 'avatars/generated/example.svg', 'avatar_path' => 'avatars/generated/example.svg',
@@ -50,38 +43,12 @@ class UserTest extends TestCase
]); ]);
$this->assertSame( $this->assertSame(
Storage::disk('avatar-test')->url('avatars/generated/example.svg'), Storage::disk('public')->url('avatars/generated/example.svg'),
$user->avatar, $user->avatar,
); );
$this->assertArrayNotHasKey('avatar_path', $user->toArray()); $this->assertArrayNotHasKey('avatar_path', $user->toArray());
} }
public function test_user_avatar_attribute_returns_temporary_url_for_private_storage(): void
{
config([
'filesystems.avatar_disk' => 's3',
'filesystems.avatar_urls_temporary' => true,
'filesystems.avatar_url_expiration_minutes' => 10,
]);
$disk = Mockery::mock(FilesystemAdapter::class);
$disk->shouldReceive('temporaryUrl')
->once()
->with(
'avatars/uploads/private.png',
Mockery::on(fn (mixed $expiration): bool => $expiration instanceof DateTimeInterface),
)
->andReturn('https://object-storage.test/signed-avatar');
Storage::shouldReceive('disk')->once()->with('s3')->andReturn($disk);
$user = User::factory()->create([
'avatar_path' => 'avatars/uploads/private.png',
'avatar_is_generated' => false,
]);
$this->assertSame('https://object-storage.test/signed-avatar', $user->avatar);
}
public function test_user_can_identify_uploaded_avatars(): void public function test_user_can_identify_uploaded_avatars(): void
{ {
$generated = User::factory()->make([ $generated = User::factory()->make([
@@ -3,8 +3,10 @@
namespace Tests\Feature\Projects; namespace Tests\Feature\Projects;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Models\BoardColumn; use App\Models\BoardColumn;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketStatus; use App\Models\TicketStatus;
use App\Models\User; use App\Models\User;
@@ -31,6 +33,15 @@ class ProjectBoardSettingsControllerTest extends TestCase
'position' => 1, 'position' => 1,
]); ]);
$template = ProjectTemplate::factory()->create([
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($project, [
DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW,
]),
'copy_areas' => [DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW],
'name' => 'Starter Workflow',
]);
$project->update(['project_template_id' => $template->id]);
$this->syncProjectPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user) $this->actingAs($user)
@@ -41,7 +52,9 @@ class ProjectBoardSettingsControllerTest extends TestCase
->where('project.id', $project->id) ->where('project.id', $project->id)
->where('backlogTicketStatuses.0.name', 'Backlog') ->where('backlogTicketStatuses.0.name', 'Backlog')
->where('boardColumns.0.name', 'Todo') ->where('boardColumns.0.name', 'Todo')
->where('ticketStatuses.0.name', 'Backlog'), ->where('ticketStatuses.0.name', 'Backlog')
->where('templateState.templateName', 'Starter Workflow')
->where('templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW),
); );
} }
@@ -2,12 +2,20 @@
namespace Tests\Feature\Projects; namespace Tests\Feature\Projects;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn; use App\Models\BoardColumn;
use App\Models\NotificationAction;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\RepositoryConnection;
use App\Models\Team; use App\Models\Team;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\TicketLinkType;
use App\Models\TicketPriority;
use App\Models\TicketStatus; use App\Models\TicketStatus;
use App\Models\TicketType;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert; use Inertia\Testing\AssertableInertia as Assert;
@@ -100,12 +108,40 @@ class ProjectControllerTest extends TestCase
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$this->syncGlobalPermissions($user, ['project:create']); $this->syncGlobalPermissions($user, ['project:create']);
$visibleProject = Project::factory()->create([
'name' => 'Visible Source',
'key' => 'SRC1',
]);
Project::factory()->create([
'name' => 'Hidden Source',
'key' => 'SRC2',
]);
$template = ProjectTemplate::factory()->create([
'name' => 'Reusable Setup',
'slug' => 'reusable-setup',
'source_project_id' => $visibleProject->id,
]);
ProjectTemplate::factory()->create([
'name' => 'Hidden Base',
'slug' => 'hidden-base',
'project_id' => $visibleProject->id,
'source_project_id' => $visibleProject->id,
'is_published' => false,
]);
$this->syncProjectPermissions($visibleProject, $user, ['view']);
$this->actingAs($user) $this->actingAs($user)
->get(route('projects.create')) ->get(route('projects.create', ['template' => $template->id]))
->assertOk() ->assertOk()
->assertInertia(fn (Assert $page) => $page ->assertInertia(fn (Assert $page) => $page
->component('projects/Create'), ->component('projects/Create')
->has('copyAreaOptions', count(DuplicateProjectConfiguration::copyableAreas()))
->has('sourceProjects', 1)
->has('sourceTemplates', 1)
->where('sourceProjects.0.id', $visibleProject->id)
->where('sourceTemplates.0.id', $template->id)
->where('selectedSourceTemplateId', $template->id)
->where('copyAreaOptions.0.value', DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS),
); );
} }
@@ -131,6 +167,7 @@ class ProjectControllerTest extends TestCase
$response = $this->actingAs($user) $response = $this->actingAs($user)
->post(route('projects.store'), [ ->post(route('projects.store'), [
'creation_mode' => 'scratch',
'name' => 'Kanban Foundation', 'name' => 'Kanban Foundation',
'key' => 'core', 'key' => 'core',
]); ]);
@@ -144,6 +181,18 @@ class ProjectControllerTest extends TestCase
'key' => 'CORE', 'key' => 'CORE',
]); ]);
$project->refresh();
$this->assertNotNull($project->project_template_id);
$this->assertDatabaseHas('project_templates', [
'id' => $project->project_template_id,
'project_id' => $project->id,
'source_project_id' => $project->id,
'is_published' => false,
'slug' => 'core-base-template',
]);
$this->assertDatabaseHas('permission_assignments', [ $this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $user->id, 'assignee_id' => $user->id,
'assignee_type' => $user->getMorphClass(), 'assignee_type' => $user->getMorphClass(),
@@ -187,6 +236,220 @@ class ProjectControllerTest extends TestCase
->assertRedirect(route('projects.create')); ->assertRedirect(route('projects.create'));
} }
public function test_authenticated_users_can_create_projects_from_saved_templates(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['project:create']);
$sourceProject = Project::factory()->create([
'key' => 'SRC1',
'name' => 'Source Project',
'show_author' => false,
'show_story_points' => false,
]);
$template = ProjectTemplate::factory()->create([
'source_project_id' => $sourceProject->id,
'name' => 'Kanban Template',
'slug' => 'kanban-template',
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
'configuration_snapshot' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => false,
'show_story_points' => false,
],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
[
'name' => 'Bug',
'position' => 1,
'is_parent' => false,
],
],
],
]);
$response = $this->actingAs($user)
->post(route('projects.store'), [
'creation_mode' => 'template',
'name' => 'Template Based Project',
'key' => 'TMP1',
'source_template_id' => $template->id,
]);
$project = Project::query()->where('key', 'TMP1')->firstOrFail();
$response->assertRedirect(route('projects.show', $project));
$project->refresh();
$this->assertSame($template->id, $project->project_template_id);
$this->assertDatabaseMissing('project_templates', [
'project_id' => $project->id,
'is_published' => false,
]);
$this->assertSame(false, $project->show_author);
$this->assertSame(false, $project->show_story_points);
$this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id,
'project_template_id' => $template->id,
'name' => 'Bug',
'position' => 1,
'is_parent' => false,
]);
$this->assertDatabaseMissing('tickets', [
'project_id' => $project->id,
]);
}
public function test_authenticated_users_can_duplicate_projects_with_selected_settings_only(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['project:create']);
$sourceProject = Project::factory()->create([
'key' => 'SRC1',
'name' => 'Source Project',
'show_author' => false,
'show_story_points' => false,
]);
$this->syncProjectPermissions($sourceProject, $user, ['view']);
$sourceProject->ticketTypes()->delete();
$sourceProject->ticketPriorities()->delete();
$sourceProject->ticketStatuses()->delete();
$sourceProject->boardColumns()->delete();
$todoColumn = BoardColumn::factory()->create([
'project_id' => $sourceProject->id,
'name' => 'Todo',
'position' => 1,
]);
$doingColumn = BoardColumn::factory()->create([
'project_id' => $sourceProject->id,
'name' => 'Doing',
'position' => 2,
]);
$backlogStatus = TicketStatus::factory()->create([
'project_id' => $sourceProject->id,
'board_column_id' => null,
'name' => 'Planned',
'position' => 1,
]);
$todoStatus = TicketStatus::factory()->create([
'project_id' => $sourceProject->id,
'board_column_id' => $todoColumn->id,
'name' => 'Todo',
'position' => 1,
]);
$doingStatus = TicketStatus::factory()->create([
'project_id' => $sourceProject->id,
'board_column_id' => $doingColumn->id,
'name' => 'Doing',
'position' => 1,
]);
$todoStatus->allowedTargetStatuses()->sync([$doingStatus->id]);
$backlogStatus->allowedTargetStatuses()->sync([$todoStatus->id]);
TicketType::factory()->create([
'project_id' => $sourceProject->id,
'name' => 'Bug',
'position' => 1,
]);
TicketType::factory()->create([
'project_id' => $sourceProject->id,
'name' => 'Epic',
'position' => 2,
'is_parent' => true,
]);
TicketPriority::factory()->create([
'project_id' => $sourceProject->id,
'name' => 'Urgent',
'position' => 1,
]);
TicketFieldDefinition::factory()->create([
'project_id' => $sourceProject->id,
'field_type' => 'number',
'label' => 'Story estimate',
'position' => 1,
]);
TicketLinkType::factory()->create([
'project_id' => $sourceProject->id,
'label' => 'Blocks',
'counterpart_label' => 'Blocked by',
'position' => 1,
]);
RepositoryConnection::factory()->create([
'project_id' => $sourceProject->id,
'repository_full_name' => 'OpenSource/source-project',
]);
NotificationAction::factory()->create([
'project_id' => $sourceProject->id,
'name' => 'Notify board changes',
]);
Ticket::factory()->create([
'project_id' => $sourceProject->id,
'sequence_number' => 42,
'title' => 'Existing ticket',
]);
$response = $this->actingAs($user)
->post(route('projects.store'), [
'name' => 'Duplicated Project',
'key' => 'dupe',
'creation_mode' => 'duplicate',
'source_project_id' => $sourceProject->id,
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
],
]);
$project = Project::query()->where('key', 'DUPE')->firstOrFail();
$response->assertRedirect(route('projects.show', $project));
$this->assertSame('Duplicated Project', $project->name);
$this->assertFalse($project->show_author);
$this->assertFalse($project->show_story_points);
$this->assertSame(0, $project->tickets()->count());
$this->assertSame(['Todo', 'Doing'], $project->boardColumns()->pluck('name')->all());
$this->assertSame(['Planned', 'Todo', 'Doing'], $project->ticketStatuses()->pluck('name')->all());
$this->assertSame(['Bug', 'Epic'], $project->ticketTypes()->pluck('name')->all());
$this->assertSame(['Story estimate'], $project->ticketFieldDefinitions()->pluck('label')->all());
$this->assertSame(['Medium'], $project->ticketPriorities()->pluck('name')->all());
$this->assertCount(0, $project->ticketLinkTypes);
$this->assertCount(0, $project->repositoryConnections);
$this->assertCount(0, $project->notificationActions);
$duplicatedTodoStatus = $project->ticketStatuses()->where('name', 'Todo')->firstOrFail();
$this->assertSame(['Doing'], $duplicatedTodoStatus->allowedTargetStatuses()->pluck('name')->all());
}
public function test_project_duplication_requires_accessible_source_projects(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['project:create']);
$sourceProject = Project::factory()->create();
$this->actingAs($user)
->from(route('projects.create'))
->post(route('projects.store'), [
'name' => 'Denied Duplicate',
'key' => 'deny',
'creation_mode' => 'duplicate',
'source_project_id' => $sourceProject->id,
])
->assertSessionHasErrors('source_project_id')
->assertRedirect(route('projects.create'));
}
/** /**
* Ensure project keys cannot exceed five characters. * Ensure project keys cannot exceed five characters.
*/ */
@@ -332,6 +595,159 @@ class ProjectControllerTest extends TestCase
); );
} }
public function test_project_admins_can_switch_projects_to_another_template(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
'name' => 'Core Platform',
'show_author' => false,
'show_story_points' => false,
]);
$currentTemplate = ProjectTemplate::factory()->create([
'name' => 'Current Template',
'slug' => 'current-template',
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
'configuration_snapshot' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => false,
'show_story_points' => false,
],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
['name' => 'Bug', 'position' => 1, 'is_parent' => false],
],
],
]);
$newTemplate = ProjectTemplate::factory()->create([
'name' => 'Operations Template',
'slug' => 'operations-template',
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
'configuration_snapshot' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => true,
'show_story_points' => true,
],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
['name' => 'Incident', 'position' => 1, 'is_parent' => false],
['name' => 'Maintenance', 'position' => 2, 'is_parent' => false],
],
],
]);
$project->update(['project_template_id' => $currentTemplate->id]);
$project->ticketTypes()->delete();
TicketType::factory()->create(['project_id' => $project->id, 'name' => 'Bug', 'position' => 1, 'is_parent' => false]);
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->from(route('projects.settings.general', $project))
->post(route('projects.settings.general.switch-template', $project), [
'confirmation_key' => 'CORE',
'source_template_id' => $newTemplate->id,
])
->assertRedirect(route('projects.settings.general', $project))
->assertSessionHas('status', 'Core Platform now uses the Operations Template template.');
$project->refresh();
$this->assertSame($newTemplate->id, $project->project_template_id);
$this->assertTrue($project->show_author);
$this->assertTrue($project->show_story_points);
$this->assertSame(['Incident', 'Maintenance'], $project->ticketTypes()->pluck('name')->all());
$this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id,
'project_template_id' => $newTemplate->id,
'name' => 'Incident',
]);
}
public function test_project_admins_can_reset_projects_to_their_current_template_baseline(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
'name' => 'Core Platform',
'show_author' => false,
'show_story_points' => false,
]);
$template = ProjectTemplate::factory()->create([
'name' => 'Workflow Template',
'slug' => 'workflow-template',
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
'configuration_snapshot' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => true,
'show_story_points' => true,
],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
['name' => 'Bug', 'position' => 1, 'is_parent' => false],
['name' => 'Feature', 'position' => 2, 'is_parent' => false],
],
],
]);
$project->update(['project_template_id' => $template->id]);
$project->ticketTypes()->delete();
TicketType::factory()->create(['project_id' => $project->id, 'name' => 'Custom', 'position' => 1, 'is_parent' => false]);
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->from(route('projects.settings.general', $project))
->post(route('projects.settings.general.reset-template', $project), [
'confirmation_key' => 'CORE',
])
->assertRedirect(route('projects.settings.general', $project))
->assertSessionHas('status', 'Core Platform was reset to the Workflow Template template baseline.');
$project->refresh();
$this->assertSame($template->id, $project->project_template_id);
$this->assertTrue($project->show_author);
$this->assertTrue($project->show_story_points);
$this->assertSame(['Bug', 'Feature'], $project->ticketTypes()->pluck('name')->all());
$this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id,
'project_template_id' => $template->id,
'name' => 'Bug',
]);
}
public function test_project_template_switch_requires_exact_project_key_confirmation(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
$project->update([
'project_template_id' => ProjectTemplate::factory()->create()->id,
]);
$newTemplate = ProjectTemplate::factory()->create();
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->from(route('projects.settings.general', $project))
->post(route('projects.settings.general.switch-template', $project), [
'confirmation_key' => 'core',
'source_template_id' => $newTemplate->id,
])
->assertRedirect(route('projects.settings.general', $project))
->assertSessionHasErrors('confirmation_key');
$this->assertNotSame($newTemplate->id, $project->fresh()->project_template_id);
}
/** /**
* Ensure admins can delete projects and their dependent project-scoped data. * Ensure admins can delete projects and their dependent project-scoped data.
*/ */
@@ -2,10 +2,12 @@
namespace Tests\Feature\Projects; namespace Tests\Feature\Projects;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Enums\NotificationChannel; use App\Enums\NotificationChannel;
use App\Enums\NotificationEventType; use App\Enums\NotificationEventType;
use App\Models\NotificationAction; use App\Models\NotificationAction;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert; use Inertia\Testing\AssertableInertia as Assert;
@@ -41,6 +43,15 @@ class ProjectNotificationSettingsControllerTest extends TestCase
], ],
]); ]);
$template = ProjectTemplate::factory()->create([
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($project, [
DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS,
]),
'copy_areas' => [DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS],
'name' => 'Alert Template',
]);
$project->update(['project_template_id' => $template->id]);
$this->syncProjectPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user) $this->actingAs($user)
@@ -72,6 +83,8 @@ class ProjectNotificationSettingsControllerTest extends TestCase
->where('templateVariables.6.value', '$actor_name') ->where('templateVariables.6.value', '$actor_name')
->where('templateVariables.7.value', '$assignee_from') ->where('templateVariables.7.value', '$assignee_from')
->where('templateVariables.8.value', '$assignee_to') ->where('templateVariables.8.value', '$assignee_to')
->where('templateState.templateName', 'Alert Template')
->where('templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS)
->missing('notificationActions.0.delivery_url'), ->missing('notificationActions.0.delivery_url'),
); );
} }

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