diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d7d2431 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,49 @@ +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/go/build-context-dockerignore/ + +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.gitattributes +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.next +**/.cache +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/docker-compose* +**/compose.y*ml +**/compose.*.y*ml +!**/composer.json +!**/composer.lock +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +vendor +**/.DS_Store +.editorconfig +.env.example +.env.mariadb +.env.mariadb.example +.env.production +LICENSE +README.md + +# Ignore development-related files and directories +# For development environment, we will use mount bind to mount the source code to the container +# So we don't need to copy the source code to the container for development +tests/ +phpunit.xml +phpunit.xml.dist diff --git a/.env.example b/.env.example index c0660ea..f44b3bc 100644 --- a/.env.example +++ b/.env.example @@ -1,65 +1,34 @@ -APP_NAME=Laravel +APP_NAME=kanbangit APP_ENV=local APP_KEY= APP_DEBUG=true +APP_TIMEZONE=Europe/Berlin APP_URL=http://localhost +API_KEY= APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US -APP_MAINTENANCE_DRIVER=file -# APP_MAINTENANCE_STORE=database +LOG_STACK=daily -# PHP_CLI_SERVER_WORKERS=4 +DB_CONNECTION=mariadb +DB_HOST=kanbangit-db +DB_PORT=3306 +DB_DATABASE=kanbangit +DB_USERNAME= +DB_PASSWORD= -BCRYPT_ROUNDS=12 - -LOG_CHANNEL=stack -LOG_STACK=single -LOG_DEPRECATIONS_CHANNEL=null -LOG_LEVEL=debug - -DB_CONNECTION=sqlite -# DB_HOST=127.0.0.1 -# DB_PORT=3306 -# DB_DATABASE=laravel -# DB_USERNAME=root -# DB_PASSWORD= - -SESSION_DRIVER=database -SESSION_LIFETIME=120 -SESSION_ENCRYPT=false -SESSION_PATH=/ -SESSION_DOMAIN=null - -BROADCAST_CONNECTION=log -FILESYSTEM_DISK=local -QUEUE_CONNECTION=database - -CACHE_STORE=database -# CACHE_PREFIX= - -MEMCACHED_HOST=127.0.0.1 - -REDIS_CLIENT=phpredis -REDIS_HOST=127.0.0.1 -REDIS_PASSWORD=null -REDIS_PORT=6379 - -MAIL_MAILER=log -MAIL_SCHEME=null -MAIL_HOST=127.0.0.1 -MAIL_PORT=2525 -MAIL_USERNAME=null -MAIL_PASSWORD=null -MAIL_FROM_ADDRESS="hello@example.com" +#Default +MAIL_MAILER=smtp +MAIL_HOST=mail.example.de +MAIL_PORT=587 +MAIL_USERNAME=no-reply@example.com +MAIL_PASSWORD= +MAIL_ENCRYPTION=tls +MAIL_FROM_ADDRESS=no-reply@example.com MAIL_FROM_NAME="${APP_NAME}" -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -AWS_DEFAULT_REGION=us-east-1 -AWS_BUCKET= -AWS_USE_PATH_STYLE_ENDPOINT=false +FILESYSTEM_DISK=public VITE_APP_NAME="${APP_NAME}" diff --git a/.env.mariadb.example b/.env.mariadb.example new file mode 100644 index 0000000..5200427 --- /dev/null +++ b/.env.mariadb.example @@ -0,0 +1,4 @@ +MARIADB_DATABASE=kanbangit +MARIADB_USER=kanbangit +MARIADB_PASSWORD= +MARIADB_ROOT_PASSWORD= diff --git a/.gitignore b/.gitignore index 5504403..a27aa7e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ .DS_Store .env .env.backup +.env.mariadb .env.production .phpactor.json .phpunit.result.cache diff --git a/app/Http/Controllers/ProjectBoardSettingsController.php b/app/Http/Controllers/ProjectBoardSettingsController.php index ccb9e1a..b6aca5b 100644 --- a/app/Http/Controllers/ProjectBoardSettingsController.php +++ b/app/Http/Controllers/ProjectBoardSettingsController.php @@ -9,6 +9,7 @@ use App\Models\BoardColumn; use App\Models\Project; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Inertia\Inertia; @@ -42,12 +43,13 @@ class ProjectBoardSettingsController extends Controller $this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), 403, ); + $validated = $request->validated(); - $desiredPosition = $request->position() ?? $project->boardColumns()->count() + 1; + $desiredPosition = Arr::get($validated, 'position') ?? $project->boardColumns()->count() + 1; - $boardColumn = DB::transaction(function () use ($project, $request, $desiredPosition): BoardColumn { + $boardColumn = DB::transaction(function () use ($project, $validated, $desiredPosition): BoardColumn { $boardColumn = $project->boardColumns()->create([ - 'name' => $request->validated('name'), + 'name' => Arr::get($validated, 'name'), 'position' => $project->boardColumns()->count() + 1, ]); @@ -69,13 +71,14 @@ class ProjectBoardSettingsController extends Controller $this->projectPermissionService->userCanManageProjectAccess($request->user(), $project), 403, ); + $validated = $request->validated(); - DB::transaction(function () use ($boardColumn, $project, $request): void { + DB::transaction(function () use ($boardColumn, $project, $validated): void { $boardColumn->update([ - 'name' => $request->validated('name'), + 'name' => Arr::get($validated, 'name'), ]); - $this->reorderColumns($project, $boardColumn, $request->position()); + $this->reorderColumns($project, $boardColumn, Arr::get($validated, 'position')); }); return to_route('projects.settings.board', $project) diff --git a/app/Http/Controllers/ProjectTicketLinkController.php b/app/Http/Controllers/ProjectTicketLinkController.php index fd9f3f2..7dfebae3 100644 --- a/app/Http/Controllers/ProjectTicketLinkController.php +++ b/app/Http/Controllers/ProjectTicketLinkController.php @@ -59,6 +59,17 @@ class ProjectTicketLinkController extends Controller ], ); + $this->createTicketWorklogEntry->handle( + $linkedTicket, + $request->user(), + 'link_updated', + "Linked {$ticket->ticket_key} as {$ticketLink->ticketLinkType->label}.", + [ + 'linked_ticket_key' => $ticket->ticket_key, + 'ticket_link_type' => $ticketLink->ticketLinkType->label, + ], + ); + return $ticketLink; }); @@ -103,6 +114,17 @@ class ProjectTicketLinkController extends Controller 'ticket_link_type' => $label, ], ); + + $this->createTicketWorklogEntry->handle( + $linkedTicket, + $request->user(), + 'link_removed', + "Removed {$label} link to {$ticket->ticket_key}.", + [ + 'linked_ticket_key' => $ticket->ticket_key, + 'ticket_link_type' => $label, + ], + ); }); return to_route('projects.tickets.show', [$project, $ticket]) diff --git a/app/Http/Requests/StoreBoardColumnRequest.php b/app/Http/Requests/StoreBoardColumnRequest.php index 29e5a0b..212bbf2 100644 --- a/app/Http/Requests/StoreBoardColumnRequest.php +++ b/app/Http/Requests/StoreBoardColumnRequest.php @@ -27,15 +27,4 @@ class StoreBoardColumnRequest extends FormRequest 'position' => ['nullable', 'integer', 'min:1'], ]; } - - public function position(): ?int - { - $position = $this->validated('position'); - - if ($position === null) { - return null; - } - - return (int) $position; - } } diff --git a/app/Http/Requests/UpdateBoardColumnRequest.php b/app/Http/Requests/UpdateBoardColumnRequest.php index 22f0520..5de51c8 100644 --- a/app/Http/Requests/UpdateBoardColumnRequest.php +++ b/app/Http/Requests/UpdateBoardColumnRequest.php @@ -27,9 +27,4 @@ class UpdateBoardColumnRequest extends FormRequest 'position' => ['required', 'integer', 'min:1'], ]; } - - public function position(): int - { - return (int) $this->validated('position'); - } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index bf402d4..88484df 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -7,6 +7,7 @@ use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; @@ -25,6 +26,11 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + + if ($this->app->environment() !== 'local') { + URL::forceScheme('https'); + } + $this->configureAuthorization(); $this->configureDefaults(); } diff --git a/database/migrations/2026_03_24_234735_create_teams_table.php b/database/migrations/2026_03_24_234734_create_teams_table.php similarity index 100% rename from database/migrations/2026_03_24_234735_create_teams_table.php rename to database/migrations/2026_03_24_234734_create_teams_table.php diff --git a/database/migrations/2026_03_25_070706_create_ticket_field_values_table.php b/database/migrations/2026_03_25_070706_create_ticket_field_values_table.php index 57d6bba..5096546 100644 --- a/database/migrations/2026_03_25_070706_create_ticket_field_values_table.php +++ b/database/migrations/2026_03_25_070706_create_ticket_field_values_table.php @@ -16,7 +16,7 @@ return new class extends Migration $table->foreignId('ticket_id')->constrained()->cascadeOnDelete(); $table->foreignId('ticket_field_definition_id')->constrained()->cascadeOnDelete(); $table->foreignId('value_user_id')->nullable()->constrained('users')->nullOnDelete(); - $table->decimal('value_number', 10, 2)->nullable(); + $table->decimal('value_number', 16, 4)->nullable(); $table->timestamps(); $table->unique(['ticket_id', 'ticket_field_definition_id']); diff --git a/database/migrations/2026_03_26_075849_create_repository_connections_table.php b/database/migrations/2026_03_26_075849_create_repository_connections_table.php index 0de4858..0472c34 100644 --- a/database/migrations/2026_03_26_075849_create_repository_connections_table.php +++ b/database/migrations/2026_03_26_075849_create_repository_connections_table.php @@ -28,7 +28,7 @@ return new class extends Migration 'provider', 'base_url', 'provider_repository_id', - ]); + ], 'repo_connections_project_provider_repo_unique'); }); } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..07cd981 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,69 @@ +services: + web: + container_name: kanbangit-web + build: + context: . + dockerfile: ./docker/nginx/Dockerfile + restart: unless-stopped + ports: + - "8080:80" + volumes: + - kanbangit-storage:/var/www/storage:ro + - kanbangit-public-assets:/var/www/public/build:ro + env_file: + - .env.production + networks: + - kanbangit-web + depends_on: + php-fpm: + condition: service_healthy + + php-fpm: + container_name: kanbangit-php-fpm + build: + context: . + dockerfile: ./docker/php-fpm/Dockerfile + target: production + restart: unless-stopped + volumes: + - kanbangit-storage:/var/www/storage + - kanbangit-public-assets:/var/www/public/build + env_file: + - .env.production + networks: + - kanbangit-web + - kanbangit-db + healthcheck: + test: ["CMD-SHELL", "php-fpm-healthcheck || exit 1"] + interval: 10s + timeout: 5s + retries: 3 + depends_on: + mariadb: + condition: service_healthy + + mariadb: + container_name: kanbangit-db + image: mariadb:11.4 + restart: unless-stopped + env_file: + - .env.mariadb + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + volumes: + - mariadb-data-production:/var/lib/mysql + networks: + - kanbangit-db + healthcheck: + test: [ "CMD", "healthcheck.sh", "--connect", "--innodb_initialized" ] + start_period: 10s + interval: 10s + timeout: 5s + retries: 3 +networks: + kanbangit-db: + kanbangit-web: + +volumes: + mariadb-data-production: + kanbangit-storage: + kanbangit-public-assets: diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile new file mode 100644 index 0000000..35e463b --- /dev/null +++ b/docker/nginx/Dockerfile @@ -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;"] diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 0000000..09390d3 --- /dev/null +++ b/docker/nginx/nginx.conf @@ -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; + } + } +} diff --git a/docker/php-fpm/Dockerfile b/docker/php-fpm/Dockerfile new file mode 100644 index 0000000..99bb566 --- /dev/null +++ b/docker/php-fpm/Dockerfile @@ -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"] diff --git a/docker/php-fpm/entrypoint.sh b/docker/php-fpm/entrypoint.sh new file mode 100644 index 0000000..683145a --- /dev/null +++ b/docker/php-fpm/entrypoint.sh @@ -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 "$@" diff --git a/docs/README.md b/docs/README.md index 97b2034..a1023be 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,8 @@ KanbanGit is intended to become a self-hostable project management application w The product centers around projects. Each project owns its own board configuration, backlog, ticket metadata, ticket hierarchy, and repository connections. The architecture must support application-wide administration plus project-scoped permissions that can be assigned to users and teams. +First Mockup https://stitch.withgoogle.com/projects/353263382234239260 + ## Documentation map - [Product Spec](product-spec.md) describes expected product behavior, user-visible rules, and scope boundaries. diff --git a/docs/missing-features.md b/docs/missing-features.md index cb617eb..2bd1b42 100644 --- a/docs/missing-features.md +++ b/docs/missing-features.md @@ -2,5 +2,70 @@ ### Ticket +- general: + - new skill, user resources + - ueberall $array['''] ersetzen durch Arr::get() + - (mehr über middlewares steuern) + - why uses everywhere forceFill? + - $request->validated('xxx') ersetzten durch: + - ``` $validated = $request->validate(); $xxx = Arr:get($validated, 'xxx'); ``` + - oder: ```$request->position()```, methoden entfernen, wie: + ``` + public function position(): ?int { + position = this->validated('position'); + + if ($position === null) { + return null; + } + + return (int) $position; + } + ``` + - app/Http/Requests/UpdateProjectTicketRequest.php and other ProjectTicket only to Ticket... + - mehrere actions/quereys in services auslagern + - 02: - - Delete Projekt as Admin \ No newline at end of file + - Delete Projekt as Admin +- 03 + - permission models? eg.g syncUserPermissions uses only db table + - mit morph auf perm user (z.b. team oder user) + - Project Permissions Service + - syncTeamMembers(Team $team, array $userIds): void → ist wrong here + - gate statt has permission methods + - permission for creating teams without beeing admind -> teamowner + - team roles +- 05 + - ticket type remove build in + - → add is parentBool to ticketType -> remove system_key (maybe add slug) + - auto generate one parent one task on new project as now + - disable delete last child ticket type + - initializeProjectTicketMetadata nur beim init benutzen + - every form request removed unnecessary methods: like position in StoreProjectNamedSettingRequest + - use Arr:get(\$validated, 'value'). instead of these messages or \$request->value + - Made ticket priority more static(change to has one) in ticket, but only show if there are more than one configured. + - made story points more static(change to table field in table), but only show if checked in project settings → new value on Project: hasStoryPoints + - more ticket field types (string, string array, boolean, precision (double)) + - abortUnlessCanManage(xxx) entfernen, auch gate benutzen (ProjectTicketMetadataSettingsController) + - ticket assignee fest einbauen in ticket +- 06 + - add Ticket Status + - ticketstatus can have one or more board columns + - one board cloumn cant be in two or more ticket statuses + - replace board_colum_id on ticket with ticket status id + - ticket status has an n:m relation in which relatin the ticket can switch + - (ui friendly example all following or select) + - change Project Backlog Controller → Project Controller + - remove store from project board controller and route projects/{project}/backlog, change to ticket controller, create +- 08 + - worklog überarbeiten → type als worklog enum → json je nach enum type + - UpdateProjectTicket aufräumen +- 09 + - app/Http/Controllers/ProjectTicketController.php parent ids anderes queren. nicht alle child ids berechnen und excluden -> immer parent vom parent bis null + + + +- later: + - if deleting ticket type select a new ticket type which all existing ticket should get or select delete all tickets of that type + - repository_connections check base Url maybe unnessecary? + - Git listBranches replace baseUrl, owner, rperoName with GItReprository + - replace Kanbangit with env App Name \ No newline at end of file diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index c2dcf2d..5c8302e 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -22,10 +22,10 @@ import { SidebarMenuButton, SidebarMenuItem, } from '@/components/ui/sidebar'; +import { dashboard } from '@/routes'; import { index as adminTeams } from '@/routes/admin/teams'; import { index as adminUsers } from '@/routes/admin/users'; -import { index as projectsIndex } from '@/routes/projects/index'; -import { dashboard } from '@/routes/index'; +import { index as projectsIndex } from '@/routes/projects'; import type { NavItem } from '@/types'; const page = usePage(); diff --git a/resources/js/components/TwoFactorSetupModal.vue b/resources/js/components/TwoFactorSetupModal.vue index 6e6d96a..c69dace 100644 --- a/resources/js/components/TwoFactorSetupModal.vue +++ b/resources/js/components/TwoFactorSetupModal.vue @@ -98,6 +98,7 @@ watch( async (isOpen) => { if (!isOpen) { resetModalState(); + return; } diff --git a/resources/js/pages/teams/Index.vue b/resources/js/pages/teams/Index.vue index febce70..42702a6 100644 --- a/resources/js/pages/teams/Index.vue +++ b/resources/js/pages/teams/Index.vue @@ -1,6 +1,6 @@