From 8d00432567cc58a9b08eab331c94eb50f0c4cc8e Mon Sep 17 00:00:00 2001 From: "Daniel D." Date: Tue, 4 Nov 2025 11:46:24 +0100 Subject: [PATCH 1/2] feat(ui): add dashboard prototype --- .editorconfig | 12 + .env.example | 57 + .gitignore | 23 + Makefile | 21 + README.md | 36 +- app/Console/Kernel.php | 27 + app/Domain/CDE/Document.php | 20 + app/Domain/CDE/DocumentVersion.php | 20 + app/Domain/CDE/Transmittal.php | 16 + app/Domain/CDE/Workflow.php | 14 + app/Domain/Core/Organization.php | 18 + app/Domain/Core/Tenant.php | 20 + app/Domain/Infrastructure/AuditLog.php | 17 + app/Domain/Infrastructure/OutboxEvent.php | 15 + app/Domain/Process/ExternalApproval.php | 15 + app/Domain/Process/Gate.php | 17 + app/Domain/Process/Phase.php | 17 + app/Domain/Process/Project.php | 16 + app/Domain/Process/ProjectTemplate.php | 14 + app/Domain/Sales/Contract.php | 15 + app/Domain/Sales/Offer.php | 18 + app/Exceptions/Handler.php | 19 + .../Controllers/Api/V1/HealthController.php | 17 + app/Http/Controllers/Controller.php | 15 + app/Http/Kernel.php | 58 + app/Http/Middleware/Authenticate.php | 16 + app/Http/Middleware/EncryptCookies.php | 15 + .../PreventRequestsDuringMaintenance.php | 9 + .../Middleware/RedirectIfAuthenticated.php | 28 + app/Http/Middleware/TrimStrings.php | 18 + app/Http/Middleware/TrustHosts.php | 20 + app/Http/Middleware/TrustProxies.php | 25 + app/Http/Middleware/ValidateSignature.php | 18 + app/Http/Middleware/VerifyCsrfToken.php | 17 + app/Models/User.php | 48 + app/Providers/AppServiceProvider.php | 24 + app/Providers/AuthServiceProvider.php | 32 + app/Providers/EventServiceProvider.php | 23 + app/Providers/RouteServiceProvider.php | 21 + app/Services/GateService.php | 42 + artisan | 21 + bootstrap/app.php | 22 + bootstrap/cache/.gitignore | 2 + composer.json | 56 + config/app.php | 97 + config/auth.php | 38 + config/broadcasting.php | 36 + config/cache.php | 34 + config/cors.php | 19 + config/database.php | 95 + config/filesystems.php | 32 + config/hashing.php | 15 + config/logging.php | 47 + config/mail.php | 30 + config/pagination.php | 7 + config/pennant.php | 17 + config/permission.php | 26 + config/queue.php | 32 + config/sanctum.php | 16 + config/services.php | 18 + config/session.php | 33 + config/view.php | 9 + database/factories/UserFactory.php | 26 + .../2014_10_12_000000_create_users_table.php | 25 + ...000_create_password_reset_tokens_table.php | 21 + ..._08_19_000000_create_failed_jobs_table.php | 25 + ...01_create_personal_access_tokens_table.php | 26 + ..._01_01_000000_create_permission_tables.php | 74 + .../2025_01_01_010000_create_jobs_table.php | 25 + database/seeders/DatabaseSeeder.php | 16 + docker-compose.yml | 64 + docker/nginx/default.conf | 25 + docker/php/Dockerfile | 28 + docs/rfcs/RFC-001-modusbuild-mvp.md | 1950 +++++++++++++++++ lang/en/auth.php | 7 + lang/en/pagination.php | 6 + lang/en/passwords.php | 9 + lang/en/validation.php | 40 + package.json | 20 + phpunit.xml | 20 + postcss.config.cjs | 6 + public/.htaccess | 11 + public/index.php | 17 + resources/css/app.css | 13 + resources/js/app.ts | 4 + resources/js/bootstrap.ts | 10 + resources/views/dashboard.blade.php | 275 +++ resources/views/welcome.blade.php | 25 + routes/api.php | 11 + routes/console.php | 8 + routes/web.php | 5 + storage/.gitignore | 2 + tailwind.config.cjs | 12 + tests/CreatesApplication.php | 19 + tests/Feature/HealthCheckTest.php | 15 + tests/Pest.php | 7 + tests/TestCase.php | 10 + tests/Unit/GateServiceTest.php | 48 + tsconfig.json | 15 + vite.config.ts | 18 + 100 files changed, 4532 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 app/Console/Kernel.php create mode 100644 app/Domain/CDE/Document.php create mode 100644 app/Domain/CDE/DocumentVersion.php create mode 100644 app/Domain/CDE/Transmittal.php create mode 100644 app/Domain/CDE/Workflow.php create mode 100644 app/Domain/Core/Organization.php create mode 100644 app/Domain/Core/Tenant.php create mode 100644 app/Domain/Infrastructure/AuditLog.php create mode 100644 app/Domain/Infrastructure/OutboxEvent.php create mode 100644 app/Domain/Process/ExternalApproval.php create mode 100644 app/Domain/Process/Gate.php create mode 100644 app/Domain/Process/Phase.php create mode 100644 app/Domain/Process/Project.php create mode 100644 app/Domain/Process/ProjectTemplate.php create mode 100644 app/Domain/Sales/Contract.php create mode 100644 app/Domain/Sales/Offer.php create mode 100644 app/Exceptions/Handler.php create mode 100644 app/Http/Controllers/Api/V1/HealthController.php create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Kernel.php create mode 100644 app/Http/Middleware/Authenticate.php create mode 100644 app/Http/Middleware/EncryptCookies.php create mode 100644 app/Http/Middleware/PreventRequestsDuringMaintenance.php create mode 100644 app/Http/Middleware/RedirectIfAuthenticated.php create mode 100644 app/Http/Middleware/TrimStrings.php create mode 100644 app/Http/Middleware/TrustHosts.php create mode 100644 app/Http/Middleware/TrustProxies.php create mode 100644 app/Http/Middleware/ValidateSignature.php create mode 100644 app/Http/Middleware/VerifyCsrfToken.php create mode 100644 app/Models/User.php create mode 100644 app/Providers/AppServiceProvider.php create mode 100644 app/Providers/AuthServiceProvider.php create mode 100644 app/Providers/EventServiceProvider.php create mode 100644 app/Providers/RouteServiceProvider.php create mode 100644 app/Services/GateService.php create mode 100755 artisan create mode 100644 bootstrap/app.php create mode 100644 bootstrap/cache/.gitignore create mode 100644 composer.json create mode 100644 config/app.php create mode 100644 config/auth.php create mode 100644 config/broadcasting.php create mode 100644 config/cache.php create mode 100644 config/cors.php create mode 100644 config/database.php create mode 100644 config/filesystems.php create mode 100644 config/hashing.php create mode 100644 config/logging.php create mode 100644 config/mail.php create mode 100644 config/pagination.php create mode 100644 config/pennant.php create mode 100644 config/permission.php create mode 100644 config/queue.php create mode 100644 config/sanctum.php create mode 100644 config/services.php create mode 100644 config/session.php create mode 100644 config/view.php create mode 100644 database/factories/UserFactory.php create mode 100644 database/migrations/2014_10_12_000000_create_users_table.php create mode 100644 database/migrations/2014_10_12_100000_create_password_reset_tokens_table.php create mode 100644 database/migrations/2019_08_19_000000_create_failed_jobs_table.php create mode 100644 database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php create mode 100644 database/migrations/2025_01_01_000000_create_permission_tables.php create mode 100644 database/migrations/2025_01_01_010000_create_jobs_table.php create mode 100644 database/seeders/DatabaseSeeder.php create mode 100644 docker-compose.yml create mode 100644 docker/nginx/default.conf create mode 100644 docker/php/Dockerfile create mode 100644 docs/rfcs/RFC-001-modusbuild-mvp.md create mode 100644 lang/en/auth.php create mode 100644 lang/en/pagination.php create mode 100644 lang/en/passwords.php create mode 100644 lang/en/validation.php create mode 100644 package.json create mode 100644 phpunit.xml create mode 100644 postcss.config.cjs create mode 100644 public/.htaccess create mode 100644 public/index.php create mode 100644 resources/css/app.css create mode 100644 resources/js/app.ts create mode 100644 resources/js/bootstrap.ts create mode 100644 resources/views/dashboard.blade.php create mode 100644 resources/views/welcome.blade.php create mode 100644 routes/api.php create mode 100644 routes/console.php create mode 100644 routes/web.php create mode 100644 storage/.gitignore create mode 100644 tailwind.config.cjs create mode 100644 tests/CreatesApplication.php create mode 100644 tests/Feature/HealthCheckTest.php create mode 100644 tests/Pest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/GateServiceTest.php create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..37cfad7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2c90ad6 --- /dev/null +++ b/.env.example @@ -0,0 +1,57 @@ +APP_NAME=ModusBuild +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=https://modusbuild.local + +LOG_CHANNEL=stack +LOG_LEVEL=debug + +DB_CONNECTION=pgsql +DB_HOST=postgres +DB_PORT=5432 +DB_DATABASE=modusbuild +DB_USERNAME=modusbuild +DB_PASSWORD=secret + +BROADCAST_DRIVER=log +CACHE_DRIVER=redis +FILESYSTEM_DISK=s3 +QUEUE_CONNECTION=redis +SESSION_DRIVER=file +SESSION_LIFETIME=120 + +MEMCACHED_HOST=127.0.0.1 + +REDIS_HOST=redis +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=smtp +MAIL_HOST=mailhog +MAIL_PORT=1025 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_ENCRYPTION=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID=localkey +AWS_SECRET_ACCESS_KEY=localsecret +AWS_DEFAULT_REGION=eu-central-1 +AWS_BUCKET=modusbuild-dev + +SANCTUM_STATEFUL_DOMAINS=modusbuild.local +SESSION_DOMAIN=modusbuild.local + +FEATURE_RLS=false +FEATURE_IFC_PREVIEW=false + +SIGNING_SECRET=change-me +MAX_UPLOAD_MB=250 + +CLAMAV_HOST=clamav +CLAMAV_PORT=3310 + +SENTRY_LARAVEL_DSN=null + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b57ba11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +/vendor/ +/node_modules/ +/public/hot +/public/storage +/storage/*.key +/storage/app/public +/storage/debugbar +/.phpunit.result.cache +/.php-cs-fixer.cache +.env +.env.* +!.env.example +.phpunit.result.cache +Homestead.json +Homestead.yaml +npm-debug.log +yarn-error.log +.idea/ +.vscode/ +.phpunit.cache +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e57b3a2 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +SHELL := /bin/bash + +up: +docker compose up -d +docker compose exec app composer install || true + +stop: +docker compose down + +logs: +docker compose logs -f app + +pint: +docker compose exec app ./vendor/bin/pint || true + +phpstan: +docker compose exec app ./vendor/bin/phpstan analyse || true + +test: +docker compose exec app ./vendor/bin/pest || true + diff --git a/README.md b/README.md index 04e3c65..2d03994 100644 --- a/README.md +++ b/README.md @@ -1 +1,35 @@ -# ModusBuild.dk \ No newline at end of file +# ModusBuild + +Monolitten for ModusBuilds MVP er bygget på Laravel 11 med Vue/Inertia frontend-opsætning. Denne repo indeholder både tekniske RFC'er og det faktiske applikationskodegrundlag. + +## Hurtig start + +1. Kopiér `.env.example` til `.env` og opdater eventuelle hemmeligheder. +2. Start udviklingsmiljøet: + + ```bash + make up + ``` + + Første kørsel forsøger at installere Composer-afhængigheder inde i containeren. I miljøer uden netværksadgang skal installationen køres lokalt på en maskine med adgang og artefakterne mountes ind i containeren. + +3. Installer Node-afhængigheder og start Vite-dev serveren til frontend: + + ```bash + npm install + npm run dev + ``` + + Mangler der netværksadgang, kan pakker installeres på en ekstern maskine og kopieres ind via bind mounts. + +4. Besøg `http://localhost:8080` for Laravel-app'en og `http://localhost:5173` for Vite dev server proxy. + +## Kvalitetssikring + +- `make pint` – kører Laravel Pint kodeformattering. +- `make phpstan` – kører Larastan statisk analyse. +- `make test` – kører Pest test-suiten. + +## Dokumentation + +- [RFC-001: ModusBuild – MVP teknisk specifikation (Draft)](docs/rfcs/RFC-001-modusbuild-mvp.md) diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php new file mode 100644 index 0000000..e6b9960 --- /dev/null +++ b/app/Console/Kernel.php @@ -0,0 +1,27 @@ +command('inspire')->hourly(); + } + + /** + * Register the commands for the application. + */ + protected function commands(): void + { + $this->load(__DIR__.'/Commands'); + + require base_path('routes/console.php'); + } +} diff --git a/app/Domain/CDE/Document.php b/app/Domain/CDE/Document.php new file mode 100644 index 0000000..0cee538 --- /dev/null +++ b/app/Domain/CDE/Document.php @@ -0,0 +1,20 @@ + $projectCodes + */ + public function __construct( + public string $id, + public string $tenantId, + public string $name, + public string $slug, + public array $projectCodes = [] + ) { + } +} diff --git a/app/Domain/Core/Tenant.php b/app/Domain/Core/Tenant.php new file mode 100644 index 0000000..7764a04 --- /dev/null +++ b/app/Domain/Core/Tenant.php @@ -0,0 +1,20 @@ +toString(), $name, $planId); + } +} diff --git a/app/Domain/Infrastructure/AuditLog.php b/app/Domain/Infrastructure/AuditLog.php new file mode 100644 index 0000000..6a910e2 --- /dev/null +++ b/app/Domain/Infrastructure/AuditLog.php @@ -0,0 +1,17 @@ +reportable(function (Throwable $e) { + // Custom reporting hooks can be added here. + }); + } +} diff --git a/app/Http/Controllers/Api/V1/HealthController.php b/app/Http/Controllers/Api/V1/HealthController.php new file mode 100644 index 0000000..5042367 --- /dev/null +++ b/app/Http/Controllers/Api/V1/HealthController.php @@ -0,0 +1,17 @@ +json([ + 'status' => 'ok', + 'timestamp' => now()->toIso8601String(), + ]); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..ce1176d --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,15 @@ + + */ + protected $middleware = [ + \App\Http\Middleware\TrustHosts::class, + \App\Http\Middleware\TrustProxies::class, + \Illuminate\Http\Middleware\HandleCors::class, + \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, + \App\Http\Middleware\TrimStrings::class, + \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, + \App\Http\Middleware\PreventRequestsDuringMaintenance::class, + ]; + + /** + * The application's route middleware groups. + */ + protected $middlewareGroups = [ + 'web' => [ + \App\Http\Middleware\EncryptCookies::class, + \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, + \Illuminate\Session\Middleware\StartSession::class, + \Illuminate\View\Middleware\ShareErrorsFromSession::class, + \App\Http\Middleware\VerifyCsrfToken::class, + \Illuminate\Routing\Middleware\SubstituteBindings::class, + ], + + 'api' => [ + \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, + 'throttle:api', + \Illuminate\Routing\Middleware\SubstituteBindings::class, + ], + ]; + + /** + * The application's route middleware aliases. + */ + protected $middlewareAliases = [ + 'auth' => \App\Http\Middleware\Authenticate::class, + 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, + 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, + 'can' => \Illuminate\Auth\Middleware\Authorize::class, + 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, + 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, + 'signed' => \App\Http\Middleware\ValidateSignature::class, + 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, + 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, + ]; +} diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php new file mode 100644 index 0000000..480f6ae --- /dev/null +++ b/app/Http/Middleware/Authenticate.php @@ -0,0 +1,16 @@ + + */ + protected $except = []; +} diff --git a/app/Http/Middleware/PreventRequestsDuringMaintenance.php b/app/Http/Middleware/PreventRequestsDuringMaintenance.php new file mode 100644 index 0000000..ed2e1be --- /dev/null +++ b/app/Http/Middleware/PreventRequestsDuringMaintenance.php @@ -0,0 +1,9 @@ +check()) { + return redirect(RouteServiceProvider::HOME); + } + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/TrimStrings.php b/app/Http/Middleware/TrimStrings.php new file mode 100644 index 0000000..e763dfb --- /dev/null +++ b/app/Http/Middleware/TrimStrings.php @@ -0,0 +1,18 @@ + + */ + protected $except = [ + 'password', + 'password_confirmation', + ]; +} diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php new file mode 100644 index 0000000..c9c58bd --- /dev/null +++ b/app/Http/Middleware/TrustHosts.php @@ -0,0 +1,20 @@ + + */ + public function hosts(): array + { + return [ + $this->allSubdomainsOfApplicationUrl(), + ]; + } +} diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php new file mode 100644 index 0000000..ab26280 --- /dev/null +++ b/app/Http/Middleware/TrustProxies.php @@ -0,0 +1,25 @@ +|string|null + */ + protected $proxies; + + /** + * The headers that should be used to detect proxies. + */ + protected $headers = Request::HEADER_X_FORWARDED_FOR | + Request::HEADER_X_FORWARDED_HOST | + Request::HEADER_X_FORWARDED_PORT | + Request::HEADER_X_FORWARDED_PROTO | + Request::HEADER_X_FORWARDED_AWS_ELB; +} diff --git a/app/Http/Middleware/ValidateSignature.php b/app/Http/Middleware/ValidateSignature.php new file mode 100644 index 0000000..e215889 --- /dev/null +++ b/app/Http/Middleware/ValidateSignature.php @@ -0,0 +1,18 @@ + + */ + protected $except = [ + // 'fbclid', + // 'utm_*', + ]; +} diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php new file mode 100644 index 0000000..9e86521 --- /dev/null +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -0,0 +1,17 @@ + + */ + protected $except = [ + // + ]; +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..7cf6a32 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,48 @@ + + */ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var array + */ + protected $hidden = [ + 'password', + 'remember_token', + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..452e6b6 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,24 @@ + + */ + protected $policies = []; + + /** + * Register any authentication / authorization services. + */ + public function boot(): void + { + $this->registerPolicies(); + + Gate::before(function ($user, string $ability) { + if (method_exists($user, 'isSuperAdmin') && $user->isSuperAdmin()) { + return true; + } + + return null; + }); + } +} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php new file mode 100644 index 0000000..304bcc8 --- /dev/null +++ b/app/Providers/EventServiceProvider.php @@ -0,0 +1,23 @@ +> + */ + protected $listen = []; + + /** + * Register any events for your application. + */ + public function boot(): void + { + parent::boot(); + } +} diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..32555bc --- /dev/null +++ b/app/Providers/RouteServiceProvider.php @@ -0,0 +1,21 @@ + $documents + * @param array $approvals + * @param array> $openWorkflowSteps + * + * @return array{ok: bool, reasons: array} + */ + public function validate(Gate $gate, array $documents, array $approvals, array $openWorkflowSteps): array + { + $reasons = []; + + $missingApprovals = array_filter($documents, fn (Document $doc) => $doc->status !== 'approved_to_publish'); + if (!empty($missingApprovals)) { + $reasons[] = 'All documents in the phase must be approved_to_publish.'; + } + + if (empty($approvals)) { + $reasons[] = 'External approval is required before passing the gate.'; + } + + if (!empty($openWorkflowSteps)) { + $reasons[] = 'All workflow steps must be completed before passing the gate.'; + } + + return [ + 'ok' => empty($reasons), + 'reasons' => $reasons, + ]; + } +} diff --git a/artisan b/artisan new file mode 100755 index 0000000..48d4992 --- /dev/null +++ b/artisan @@ -0,0 +1,21 @@ +#!/usr/bin/env php +make(Kernel::class); + +$status = $kernel->handle( + $input = new Symfony\Component\Console\Input\ArgvInput(), + new Symfony\Component\Console\Output\ConsoleOutput() +); + +$kernel->terminate($input, $status); + +exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..dd48d35 --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,22 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', + health: '/up' + ) + ->withMiddleware(function (Middleware $middleware) { + $middleware->alias([ + // custom middleware can be registered here + ]); + }) + ->withExceptions(function (Exceptions $exceptions) { + // register exception handling callbacks + }) + ->create(); diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ef1b1c3 --- /dev/null +++ b/composer.json @@ -0,0 +1,56 @@ +{ + "name": "modusbuild/monolith", + "description": "ModusBuild monolithic application per RFC-001", + "type": "project", + "license": "proprietary", + "require": { + "php": "^8.3", + "laravel/framework": "^11.0", + "laravel/sanctum": "^4.0", + "spatie/laravel-permission": "^6.0", + "laravel/pennant": "^1.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "pestphp/pest": "^3.0", + "pestphp/pest-plugin-laravel": "^3.0", + "nunomaduro/collision": "^7.0", + "nunomaduro/larastan": "^2.9", + "phpstan/phpstan": "^1.10" + }, + "autoload": { + "psr-4": { + "App\\": "app/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "scripts": { + "post-autoload-dump": [ + "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", + "@php artisan package:discover --ansi" + ], + "post-root-package-install": [ + "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" + ], + "post-create-project-cmd": [ + "@php artisan key:generate --ansi" + ], + "test": "vendor/bin/pest" + }, + "extra": { + "laravel": { + "dont-discover": [] + } + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..fdf3749 --- /dev/null +++ b/config/app.php @@ -0,0 +1,97 @@ + env('APP_NAME', 'ModusBuild'), + + 'env' => env('APP_ENV', 'production'), + + 'debug' => (bool) env('APP_DEBUG', false), + + 'url' => env('APP_URL', 'http://localhost'), + + 'asset_url' => env('ASSET_URL'), + + 'timezone' => 'UTC', + + 'locale' => 'en', + + 'fallback_locale' => 'en', + + 'faker_locale' => 'en_US', + + 'key' => env('APP_KEY'), + + 'cipher' => 'AES-256-CBC', + + 'maintenance' => [ + 'driver' => 'file', + ], + + 'providers' => [ + Illuminate\Auth\AuthServiceProvider::class, + Illuminate\Broadcasting\BroadcastServiceProvider::class, + Illuminate\Bus\BusServiceProvider::class, + Illuminate\Cache\CacheServiceProvider::class, + Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, + Illuminate\Cookie\CookieServiceProvider::class, + Illuminate\Database\DatabaseServiceProvider::class, + Illuminate\Encryption\EncryptionServiceProvider::class, + Illuminate\Filesystem\FilesystemServiceProvider::class, + Illuminate\Foundation\Providers\FoundationServiceProvider::class, + Illuminate\Hashing\HashServiceProvider::class, + Illuminate\Mail\MailServiceProvider::class, + Illuminate\Notifications\NotificationServiceProvider::class, + Illuminate\Pagination\PaginationServiceProvider::class, + Illuminate\Pipeline\PipelineServiceProvider::class, + Illuminate\Queue\QueueServiceProvider::class, + Illuminate\Redis\RedisServiceProvider::class, + Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, + Illuminate\Session\SessionServiceProvider::class, + Illuminate\Translation\TranslationServiceProvider::class, + Illuminate\Validation\ValidationServiceProvider::class, + Illuminate\View\ViewServiceProvider::class, + + App\Providers\AppServiceProvider::class, + App\Providers\AuthServiceProvider::class, + App\Providers\EventServiceProvider::class, + App\Providers\RouteServiceProvider::class, + ], + + 'aliases' => [ + 'App' => Illuminate\Support\Facades\App::class, + 'Arr' => Illuminate\Support\Arr::class, + 'Artisan' => Illuminate\Support\Facades\Artisan::class, + 'Auth' => Illuminate\Support\Facades\Auth::class, + 'Blade' => Illuminate\Support\Facades\Blade::class, + 'Broadcast' => Illuminate\Support\Facades\Broadcast::class, + 'Bus' => Illuminate\Support\Facades\Bus::class, + 'Cache' => Illuminate\Support\Facades\Cache::class, + 'Config' => Illuminate\Support\Facades\Config::class, + 'Cookie' => Illuminate\Support\Facades\Cookie::class, + 'Crypt' => Illuminate\Support\Facades\Crypt::class, + 'Date' => Illuminate\Support\Facades\Date::class, + 'DB' => Illuminate\Support\Facades\DB::class, + 'Event' => Illuminate\Support\Facades\Event::class, + 'File' => Illuminate\Support\Facades\File::class, + 'Gate' => Illuminate\Support\Facades\Gate::class, + 'Hash' => Illuminate\Support\Facades\Hash::class, + 'Http' => Illuminate\Support\Facades\Http::class, + 'Lang' => Illuminate\Support\Facades\Lang::class, + 'Log' => Illuminate\Support\Facades\Log::class, + 'Mail' => Illuminate\Support\Facades\Mail::class, + 'Notification' => Illuminate\Support\Facades\Notification::class, + 'Password' => Illuminate\Support\Facades\Password::class, + 'Queue' => Illuminate\Support\Facades\Queue::class, + 'RateLimiter' => Illuminate\Support\Facades\RateLimiter::class, + 'Redirect' => Illuminate\Support\Facades\Redirect::class, + 'Request' => Illuminate\Support\Facades\Request::class, + 'Response' => Illuminate\Support\Facades\Response::class, + 'Route' => Illuminate\Support\Facades\Route::class, + 'Schema' => Illuminate\Support\Facades\Schema::class, + 'Storage' => Illuminate\Support\Facades\Storage::class, + 'Str' => Illuminate\Support\Str::class, + 'URL' => Illuminate\Support\Facades\URL::class, + 'Validator' => Illuminate\Support\Facades\Validator::class, + 'View' => Illuminate\Support\Facades\View::class, + ], +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..5f7c1c2 --- /dev/null +++ b/config/auth.php @@ -0,0 +1,38 @@ + [ + 'guard' => 'web', + 'passwords' => 'users', + ], + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + + 'api' => [ + 'driver' => 'sanctum', + 'provider' => 'users', + ], + ], + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => App\Models\User::class, + ], + ], + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => 'password_reset_tokens', + 'expire' => 60, + 'throttle' => 60, + ], + ], + + 'password_timeout' => 10800, +]; diff --git a/config/broadcasting.php b/config/broadcasting.php new file mode 100644 index 0000000..75a8451 --- /dev/null +++ b/config/broadcasting.php @@ -0,0 +1,36 @@ + env('BROADCAST_DRIVER', 'log'), + + 'connections' => [ + 'pusher' => [ + 'driver' => 'pusher', + 'key' => env('PUSHER_APP_KEY'), + 'secret' => env('PUSHER_APP_SECRET'), + 'app_id' => env('PUSHER_APP_ID'), + 'options' => [ + 'cluster' => env('PUSHER_APP_CLUSTER'), + 'useTLS' => true, + ], + ], + + 'ably' => [ + 'driver' => 'ably', + 'key' => env('ABLY_KEY'), + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + ], + + 'log' => [ + 'driver' => 'log', + ], + + 'null' => [ + 'driver' => 'null', + ], + ], +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..8835376 --- /dev/null +++ b/config/cache.php @@ -0,0 +1,34 @@ + env('CACHE_DRIVER', 'file'), + + 'stores' => [ + 'apc' => [ + 'driver' => 'apc', + ], + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'table' => 'cache', + 'connection' => null, + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'cache', + ], + ], + + 'prefix' => env('CACHE_PREFIX', 'modusbuild_cache'), +]; diff --git a/config/cors.php b/config/cors.php new file mode 100644 index 0000000..bb551e5 --- /dev/null +++ b/config/cors.php @@ -0,0 +1,19 @@ + ['api/*', 'sanctum/csrf-cookie'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => explode(',', env('CORS_ALLOWED_ORIGINS', '*')), + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => ['RateLimit-Limit', 'RateLimit-Remaining', 'RateLimit-Reset', 'ETag'], + + 'max_age' => 0, + + 'supports_credentials' => true, +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..0e09745 --- /dev/null +++ b/config/database.php @@ -0,0 +1,95 @@ + env('DB_CONNECTION', 'pgsql'), + + 'connections' => [ + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DATABASE_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DATABASE_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'forge'), + 'username' => env('DB_USERNAME', 'forge'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DATABASE_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'forge'), + 'username' => env('DB_USERNAME', 'forge'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => env('DB_SSLMODE', 'prefer'), + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DATABASE_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'forge'), + 'username' => env('DB_USERNAME', 'forge'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', + 'prefix_indexes' => true, + ], + ], + + 'migrations' => 'migrations', + + 'redis' => [ + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'ModusBuild'), '_').'_database_'), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + ], + ], +]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 0000000..ebc30a4 --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,32 @@ + env('FILESYSTEM_DISK', 'local'), + + 'cloud' => env('FILESYSTEM_CLOUD', 's3'), + + 'disks' => [ + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app'), + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => env('APP_URL').'/storage', + 'visibility' => 'public', + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + ], + ], +]; diff --git a/config/hashing.php b/config/hashing.php new file mode 100644 index 0000000..9fc46f8 --- /dev/null +++ b/config/hashing.php @@ -0,0 +1,15 @@ + env('HASH_DRIVER', 'bcrypt'), + + 'bcrypt' => [ + 'rounds' => env('BCRYPT_ROUNDS', 12), + ], + + 'argon' => [ + 'memory' => 65536, + 'threads' => 1, + 'time' => 4, + ], +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..ed9da2d --- /dev/null +++ b/config/logging.php @@ -0,0 +1,47 @@ + env('LOG_CHANNEL', 'stack'), + + 'channels' => [ + 'stack' => [ + 'driver' => 'stack', + 'channels' => ['single'], + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => 14, + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'with' => [ + 'stream' => 'php://stderr', + ], + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + ], +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..404c626 --- /dev/null +++ b/config/mail.php @@ -0,0 +1,30 @@ + env('MAIL_MAILER', 'smtp'), + + 'mailers' => [ + 'smtp' => [ + 'transport' => 'smtp', + 'host' => env('MAIL_HOST', 'mailhog'), + 'port' => env('MAIL_PORT', 1025), + 'encryption' => env('MAIL_ENCRYPTION'), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + ], + + 'log' => [ + 'transport' => 'log', + ], + + 'array' => [ + 'transport' => 'array', + ], + ], + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@modusbuild.test'), + 'name' => env('MAIL_FROM_NAME', 'ModusBuild'), + ], +]; diff --git a/config/pagination.php b/config/pagination.php new file mode 100644 index 0000000..539087e --- /dev/null +++ b/config/pagination.php @@ -0,0 +1,7 @@ + 'pagination::bootstrap-5', + + 'default_simple_view' => 'pagination::simple-bootstrap-5', +]; diff --git a/config/pennant.php b/config/pennant.php new file mode 100644 index 0000000..86372a6 --- /dev/null +++ b/config/pennant.php @@ -0,0 +1,17 @@ + env('PENNANT_DRIVER', 'array'), + + 'stores' => [ + 'array' => [ + 'driver' => 'array', + ], + + 'database' => [ + 'driver' => 'database', + 'table' => 'features', + 'scope' => 'tenant_id', + ], + ], +]; diff --git a/config/permission.php b/config/permission.php new file mode 100644 index 0000000..88ed921 --- /dev/null +++ b/config/permission.php @@ -0,0 +1,26 @@ + [ + 'permission' => Spatie\Permission\Models\Permission::class, + 'role' => Spatie\Permission\Models\Role::class, + ], + + 'table_names' => [ + 'roles' => 'roles', + 'permissions' => 'permissions', + 'model_has_permissions' => 'model_has_permissions', + 'model_has_roles' => 'model_has_roles', + 'role_has_permissions' => 'role_has_permissions', + ], + + 'column_names' => [ + 'model_morph_key' => 'model_id', + ], + + 'cache' => [ + 'expiration_time' => \DateInterval::createFromDateString('24 hours'), + 'key' => 'spatie.permission.cache', + 'store' => 'default', + ], +]; diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 0000000..23ad8f5 --- /dev/null +++ b/config/queue.php @@ -0,0 +1,32 @@ + env('QUEUE_CONNECTION', 'sync'), + + 'connections' => [ + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'table' => 'jobs', + 'queue' => 'default', + 'retry_after' => 90, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => 90, + 'block_for' => null, + ], + ], + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'pgsql'), + 'table' => 'failed_jobs', + ], +]; diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..01f61b6 --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,16 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,localhost:8080,127.0.0.1:8000')), + + 'guard' => ['web'], + + 'expiration' => null, + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + 'middleware' => [ + 'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class, + 'ensure_frontend_requests_are_stateful' => Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, + ], +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..54cacbe --- /dev/null +++ b/config/services.php @@ -0,0 +1,18 @@ + [ + 'token' => env('POSTMARK_TOKEN'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'sentry' => [ + 'dsn' => env('SENTRY_DSN'), + 'release' => env('SENTRY_RELEASE'), + ], +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 0000000..d52ee5d --- /dev/null +++ b/config/session.php @@ -0,0 +1,33 @@ + env('SESSION_DRIVER', 'file'), + + 'lifetime' => env('SESSION_LIFETIME', 120), + + 'expire_on_close' => false, + + 'encrypt' => false, + + 'files' => storage_path('framework/sessions'), + + 'connection' => env('SESSION_CONNECTION'), + + 'table' => 'sessions', + + 'store' => env('SESSION_STORE'), + + 'lottery' => [2, 100], + + 'cookie' => env('SESSION_COOKIE', 'modusbuild_session'), + + 'path' => '/', + + 'domain' => env('SESSION_DOMAIN'), + + 'secure' => env('SESSION_SECURE_COOKIE'), + + 'http_only' => true, + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), +]; diff --git a/config/view.php b/config/view.php new file mode 100644 index 0000000..1f3b844 --- /dev/null +++ b/config/view.php @@ -0,0 +1,9 @@ + [ + resource_path('views'), + ], + + 'compiled' => env('VIEW_COMPILED_PATH', realpath(storage_path('framework/views'))), +]; diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..73fe8d5 --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,26 @@ + + */ +class UserFactory extends Factory +{ + protected $model = User::class; + + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => '$2y$12$'.Str::random(53), + 'remember_token' => Str::random(10), + ]; + } +} diff --git a/database/migrations/2014_10_12_000000_create_users_table.php b/database/migrations/2014_10_12_000000_create_users_table.php new file mode 100644 index 0000000..0a62245 --- /dev/null +++ b/database/migrations/2014_10_12_000000_create_users_table.php @@ -0,0 +1,25 @@ +uuid('id')->primary(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestampsTz(); + }); + } + + public function down(): void + { + Schema::dropIfExists('users'); + } +}; diff --git a/database/migrations/2014_10_12_100000_create_password_reset_tokens_table.php b/database/migrations/2014_10_12_100000_create_password_reset_tokens_table.php new file mode 100644 index 0000000..c897c7a --- /dev/null +++ b/database/migrations/2014_10_12_100000_create_password_reset_tokens_table.php @@ -0,0 +1,21 @@ +string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('password_reset_tokens'); + } +}; diff --git a/database/migrations/2019_08_19_000000_create_failed_jobs_table.php b/database/migrations/2019_08_19_000000_create_failed_jobs_table.php new file mode 100644 index 0000000..9acaacc --- /dev/null +++ b/database/migrations/2019_08_19_000000_create_failed_jobs_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + public function down(): void + { + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php b/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php new file mode 100644 index 0000000..d4c192a --- /dev/null +++ b/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php @@ -0,0 +1,26 @@ +id(); + $table->morphs('tokenable'); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestampsTz(); + }); + } + + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/2025_01_01_000000_create_permission_tables.php b/database/migrations/2025_01_01_000000_create_permission_tables.php new file mode 100644 index 0000000..0181335 --- /dev/null +++ b/database/migrations/2025_01_01_000000_create_permission_tables.php @@ -0,0 +1,74 @@ +bigIncrements('id'); + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + }); + + Schema::create('roles', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + }); + + Schema::create('model_has_permissions', function (Blueprint $table) { + $table->unsignedBigInteger('permission_id'); + $table->uuidMorphs('model'); + + $table->foreign('permission_id') + ->references('id') + ->on('permissions') + ->cascadeOnDelete(); + + $table->primary(['permission_id', 'model_id', 'model_type']); + }); + + Schema::create('model_has_roles', function (Blueprint $table) { + $table->unsignedBigInteger('role_id'); + $table->uuidMorphs('model'); + + $table->foreign('role_id') + ->references('id') + ->on('roles') + ->cascadeOnDelete(); + + $table->primary(['role_id', 'model_id', 'model_type']); + }); + + Schema::create('role_has_permissions', function (Blueprint $table) { + $table->unsignedBigInteger('permission_id'); + $table->unsignedBigInteger('role_id'); + + $table->foreign('permission_id') + ->references('id') + ->on('permissions') + ->cascadeOnDelete(); + + $table->foreign('role_id') + ->references('id') + ->on('roles') + ->cascadeOnDelete(); + + $table->primary(['permission_id', 'role_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('role_has_permissions'); + Schema::dropIfExists('model_has_roles'); + Schema::dropIfExists('model_has_permissions'); + Schema::dropIfExists('roles'); + Schema::dropIfExists('permissions'); + } +}; diff --git a/database/migrations/2025_01_01_010000_create_jobs_table.php b/database/migrations/2025_01_01_010000_create_jobs_table.php new file mode 100644 index 0000000..ba2e930 --- /dev/null +++ b/database/migrations/2025_01_01_010000_create_jobs_table.php @@ -0,0 +1,25 @@ +bigIncrements('id'); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('jobs'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..ed265f3 --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,16 @@ + med felter: ; brug uuid for pk; tilføj indekser og tenant_id. Implementér Repository + Service skeleton. Skriv controller m. endpoints fra §22 og returnér JSON resources.” + +Eksempel – Document & Version +•Migration prompt: “Skriv Laravel migrations for documents og document_versions jf. RFC‑001 §19.4 inkl. indekser og fremmednøgler; uuid pk; soft deletes fravælges.” +•Model prompt: “Eloquent models med relations + casts (classification_jsonb→array), fillable, scopes for project/status.” +•Policy prompt: “Policies der begrænser til projektroller og tenant; tests for allow/deny.” +•Service prompt: “VersioningService med bumpVersion()/bumpRevision() + unit tests.” +•Controller prompt: “Store/Show/Index; POST /versions med presigned S3 via Storage facade.” + +Eksempel – GateService + +“Implementér GateService::validate(project, nextPhase) iht. §8.2. Returnér objekt m. ok:boolean, reasons:[]. Skriv 3 Pest‑tests fra §26.” + +Eksempel – Outbox + +“Migration + Model for outbox_events; Publisher job; trait EmitsDomainEvents til atomar skrivning i samme transaktion; Pest‑tests for idempotens.” + +⸻ + +29. Miljø & konfiguration + +.env (uddrag) + +APP_ENV=local +APP_URL=https://modusbuild.local +DB_CONNECTION=pgsql +DB_HOST=postgres +DB_PORT=5432 +DB_DATABASE=modusbuild +DB_USERNAME=modusbuild +DB_PASSWORD=secret +QUEUE_CONNECTION=redis +REDIS_HOST=redis +FILESYSTEM_DISK=s3 +AWS_ACCESS_KEY_ID=xxx +AWS_SECRET_ACCESS_KEY=xxx +AWS_DEFAULT_REGION=eu-central-1 +AWS_BUCKET=modusbuild-dev +SIGNING_SECRET=change-me +MAX_UPLOAD_MB=250 +CLAMAV_HOST=clamav +CLAMAV_PORT=3310 +SANCTUM_STATEFUL_DOMAINS=modusbuild.local + +Nøgleskifter +•FEATURE_RLS=false (kan aktiveres i P2). +•FEATURE_IFC_PREVIEW=false (P3). + +⸻ + +30. Projektstruktur (foreslået) + +app/ + Domain/ + CDE/ + Process/ + Sales/ + Http/Controllers/Api/V1/ + Models/ + Policies/ + Services/ + Jobs/ + Events/ + Listeners/ + Resources/ +config/ +database/migrations/ +resources/js/ (Vue/Inertia) + +⸻ + +31. CI/CD (GitHub Actions – skitse) +•Trig på PR: composer install, cache, pint --test, phpstan, pest, laravel pint. +•Byg & deploy (main): env‑vars via secrets; migrations step; horizon restart. + +⸻ + +32. OpenAPI (skitse) + +openapi: 3.1.0 +info: + title: ModusBuild API + version: 1.0.0 +paths: + /api/v1/documents: + post: + summary: Create document + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentCreate' + responses: + '201': { description: Created } +components: + schemas: + DocumentCreate: + type: object + required: [project_id, code, title] + properties: + project_id: { type: string, format: uuid } + code: { type: string } + title: { type: string } + discipline: { type: string } + phase_code: { type: string } + classification: { type: object } + +⸻ + +33. Definition of Ready (DoR) for user stories +•API‑kontrakt defineret, felter & validering anført. +•Acceptance tests (Gherkin) skitseret. +•Sikkerhedsforventninger (roller/policies) beskrevet. +•Observability‑events/målepunkter noteret. + +⸻ + +34. Bilag: Gherkin‑scenarier (uddrag) + +Gate pass + +Given a project with phase A4 and all documents approved_to_publish +And an ExternalApproval exists for phase A4 +When I request gate review for A4 +Then the gate decision is passed +And an audit log entry is stored + +Upload & approve + +Given a document in status draft +When I upload a PDF version and approve it +Then the document status becomes approved_to_publish +And current_version_id points to the approved version + +⸻ + +35. OpenAPI.yaml (MVP) + +Maskinlæsbar API‑kontrakt for MVP. Kan importeres i Postman/Insomnia, bruges til mock servere (fx Prism) og til generering af typed klienter. + +openapi: 3.1.0 +info: + title: ModusBuild API (MVP) + version: 0.1.0 + description: Contract-first specifikation for ModusBuild v1 (MVP). Se RFC‑001 for forretningsregler. +servers: + - url: https://api.modusbuild.local + description: Local/dev +security: + - bearerAuth: [] +paths: + /api/v1/documents: + get: + summary: List documents + parameters: + - in: query + name: project_id + schema: { type: string, format: uuid } + - in: query + name: status + schema: { type: string, enum: [draft, for_review, approved_to_publish, published, superseded] } + - in: query + name: q + schema: { type: string } + - in: query + name: page + schema: { type: integer, minimum: 1 } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: '#/components/schemas/Document' } + meta: { $ref: '#/components/schemas/Pagination' } + post: + summary: Create document (metadata) + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/DocumentCreate' } + responses: + '201': + description: Created + content: + application/json: + schema: { $ref: '#/components/schemas/Document' } + /api/v1/documents/{id}: + get: + summary: Get document by id + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Document' } } } } + '404': { $ref: '#/components/responses/NotFound' } + /api/v1/documents/{id}/versions: + post: + summary: Init new version upload (presigned URL) + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [mime, size] + properties: + mime: { type: string } + size: { type: integer, minimum: 1 } + responses: + '200': + description: Upload details + content: + application/json: + schema: + type: object + properties: + upload: + type: object + properties: + url: { type: string, format: uri } + fields: { type: object, additionalProperties: true } + version: { $ref: '#/components/schemas/DocumentVersion' } + /api/v1/document-versions/{id}/approve: + post: + summary: Approve a version + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + responses: + '200': { description: Approved, content: { application/json: { schema: { $ref: '#/components/schemas/Document' } } } } + '409': { description: Conflict (policy/state fails), content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + /api/v1/transmittals: + post: + summary: Create transmittal + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/TransmittalCreate' } + responses: + '201': { description: Created, content: { application/json: { schema: { $ref: '#/components/schemas/Transmittal' } } } } + get: + summary: List transmittals + parameters: + - in: query + name: project_id + schema: { type: string, format: uuid } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/Transmittal' } } + meta: { $ref: '#/components/schemas/Pagination' } + /api/v1/transmittals/{id}: + get: + summary: Get transmittal + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Transmittal' } } } } + '404': { $ref: '#/components/responses/NotFound' } + /api/v1/projects: + get: + summary: List projects + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/Project' } } + meta: { $ref: '#/components/schemas/Pagination' } + /api/v1/projects/{id}/gates/{gateId}/request-review: + post: + summary: Request gate review/decision + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + - in: path + name: gateId + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: Decision + content: + application/json: + schema: + type: object + properties: + result: { type: string, enum: [passed, rejected] } + decided_at: { type: string, format: date-time } + reasons: { type: array, items: { type: string } } + '422': { description: Validation failed, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + /api/v1/offers/{id}/accept: + post: + summary: Accept offer → create contract and project + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: Accepted + content: + application/json: + schema: + type: object + properties: + contract: { $ref: '#/components/schemas/Contract' } + project: { $ref: '#/components/schemas/Project' } +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + responses: + NotFound: + description: Not found + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + schemas: + Pagination: + type: object + properties: + current_page: { type: integer } + per_page: { type: integer } + total: { type: integer } + Error: + type: object + properties: + message: { type: string } + code: { type: string } + Project: + type: object + properties: + id: { type: string, format: uuid } + org_id: { type: string, format: uuid } + template_id: { type: string, format: uuid } + status: { type: string, enum: [active, archived] } + code: { type: string } + Contract: + type: object + properties: + id: { type: string, format: uuid } + offer_id: { type: string, format: uuid } + signed_at: { type: string, format: date-time } + Document: + type: object + properties: + id: { type: string, format: uuid } + project_id: { type: string, format: uuid } + code: { type: string } + title: { type: string } + discipline: { type: string } + phase_code: { type: string } + classification: { type: object, additionalProperties: true } + status: + type: string + enum: [draft, for_review, approved_to_publish, published, superseded] + current_version_id: { type: string, format: uuid, nullable: true } + DocumentCreate: + type: object + required: [project_id, code, title] + properties: + project_id: { type: string, format: uuid } + code: { type: string } + title: { type: string } + discipline: { type: string } + phase_code: { type: string } + classification: { type: object, additionalProperties: true } + DocumentVersion: + type: object + properties: + id: { type: string, format: uuid } + document_id: { type: string, format: uuid } + rev: { type: string } + version: { type: string } + storage_key: { type: string } + mime: { type: string } + size: { type: integer } + approved_at: { type: string, format: date-time, nullable: true } + Transmittal: + type: object + properties: + id: { type: string, format: uuid } + project_id: { type: string, format: uuid } + number: { type: string } + recipients: { type: array, items: { type: string } } + sent_at: { type: string, format: date-time, nullable: true } + TransmittalCreate: + type: object + required: [project_id, number, recipients] + properties: + project_id: { type: string, format: uuid } + number: { type: string } + recipients: { type: array, items: { type: string, format: email } } +webhooks: + document.version.approved: + post: + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + event: { type: string, enum: [document.version.approved] } + id: { type: string } + occurred_at: { type: string, format: date-time } + tenant_id: { type: string } + data: + type: object + properties: + document_id: { type: string, format: uuid } + version_id: { type: string, format: uuid } + approved_by: { type: string, format: uuid } + + +⸻ + +36. Artisan‑kommandoer (scaffolding‑bibliotek) + +Kør disse i feature‑branches pr. modul. Tilpas namespaces til din projektstruktur (se §30). AI kan generere filindhold fra RFC‑felter og regler. + +# === Infrastruktur === +php artisan make:model Domain/Infrastructure/OutboxEvent -m +php artisan make:job OutboxPublisherJob +php artisan make:model Domain/Infrastructure/AuditLog -m + +# === Identitet / multitenancy === +php artisan make:model Domain/Core/Tenant -mfs +php artisan make:model Domain/Core/Organization -mfs +php artisan make:migration create_organization_user_table + +# === Sales (offers/contracts) === +php artisan make:model Domain/Sales/Offer -mfs +php artisan make:model Domain/Sales/Contract -mfs +php artisan make:event OfferAccepted +php artisan make:listener InitializeProjectFromOffer +php artisan make:controller Http/Controllers/Api/V1/OfferController --api + +# === Process (projects/phases/gates/external approvals) === +php artisan make:model Domain/Process/ProjectTemplate -mfs +php artisan make:model Domain/Process/Project -mfs +php artisan make:model Domain/Process/Phase -mfs +php artisan make:model Domain/Process/Gate -mfs +php artisan make:model Domain/Process/ExternalApproval -mfs +php artisan make:policy ProjectPolicy --model=Domain/Process/Project +php artisan make:controller Http/Controllers/Api/V1/ProjectController --api +php artisan make:controller Http/Controllers/Api/V1/GateController --api +php artisan make:event ProjectInitialized +php artisan make:event GatePassed +php artisan make:listener CreateProjectFromTemplate +php artisan make:service GateService # (alternativ: opret app/Services/GateService.php manuelt) + +# === CDE (documents/versions/workflows/transmittals) === +php artisan make:model Domain/CDE/Document -mfs +php artisan make:model Domain/CDE/DocumentVersion -mfs +php artisan make:model Domain/CDE/Workflow -mfs +php artisan make:model Domain/CDE/Transmittal -mfs +php artisan make:policy DocumentPolicy --model=Domain/CDE/Document +php artisan make:controller Http/Controllers/Api/V1/DocumentController --api +php artisan make:controller Http/Controllers/Api/V1/DocumentVersionController --api +php artisan make:controller Http/Controllers/Api/V1/TransmittalController --api +php artisan make:job IngestVersionJob +php artisan make:job SetCurrentIfApprovedJob +php artisan make:event DocumentVersionApproved +php artisan make:listener PublishApprovedVersion +php artisan make:resource DocumentResource +php artisan make:resource DocumentVersionResource +php artisan make:resource TransmittalResource + +# === Tests (Pest) === +php artisan make:test GateServiceTest --pest +php artisan make:test VersioningServiceTest --pest +php artisan make:test OutboxEventTest --pest +php artisan make:test DocumentPolicyTest --pest + +# === API Routes scaffold (manuelt i routes/api.php) === +# Route::prefix('v1')->middleware(['auth:sanctum'])->group(function() { ... }); + +⸻ + +37. “First commit” tjekliste (kopi‑klar) + +Repo & licens +•Init git repo + .gitignore (Laravel) +•LICENSE (MIT) +•README med runbook + +Dev‑miljø +•Docker Compose (php-fpm, nginx, postgres, redis, mailhog, optional clamav) +•.env.example (se §29) +•Makefile/NPM scripts: make up, make test, make pint + +Laravel & pakker +•Laravel install + php artisan key:generate +•Sanctum, Spatie Permissions, Pennant, Telescope (local) +•Konfigurer logging (json i prod), Horizon til queues + +Kvalitet & CI +•Pint config (pint.json) +•PHPStan/Larastan (phpstan.neon) +•Pest set‑up + tests/ mappe +•GitHub Actions workflow: pint, phpstan, pest + +Struktur +•Domænmapper: app/Domain/{CDE,Process,Sales,Core,Infrastructure} +•app/Services, app/Policies, app/Jobs, app/Events, app/Listeners, app/Http/Controllers/Api/V1 +•routes/api.php v1‑prefix + rate limit + +OpenAPI & klient +•Commit openapi.yaml (se §35) +•(Valgfrit) Installer openapi‑generator eller Orval til TS‑klient +•Postman/Insomnia collection genereret fra OpenAPI + +Observability +•Sentry DSN (prod), Telescope (local), health endpoint +•Core metrics defineret (se §14) + +Security/GDPR +•Secrets via .env/CI secrets +•Standard password/2FA politik dokumenteret +•Audit log strategi committet + +Bootstrap commits (forslag) +1.chore: bootstrap Laravel + Docker + base CI (pint/phpstan/pest) +2.feat(core): add Sanctum, Permissions, Pennant; base folders & logging +3.chore(env): add .env.example and docker configs for pg/redis/s3 +4.docs: add README and OpenAPI contract (v1 scaffolding) + +Første feature‑branch +•feature/cde-documents: migrations, models, policies, controllers, jobs, tests +•Implementér endpoints fra OpenAPI §35 for documents/versions +•Grøn CI + demo seed‑data + +⸻ + +38. Bonus: Generering af TypeScript‑klient (frontend) + +Orval (simpelt): + +npm i -D orval +# orval.config.ts peger på openapi.yaml og genererer /resources/js/api/modusbuild.ts +npx orval --config orval.config.ts + +OpenAPI Generator (alternativ): + +npm i -D @openapitools/openapi-generator-cli +npx openapi-generator-cli generate \ + -i openapi.yaml \ + -g typescript-fetch \ + -o resources/js/api + +Anvendelse i Vue (Inertia): +•Importér genereret klient, brug typed metoder i stores/components. +•Mock backend via Prism under UI‑udvikling. + +⸻ + +39. OpenAPI.yaml (udvidet – kontrakt med fejlmodel, parametre, headers, examples) + +Denne version udvider §35 og er den gældende kontrakt (rev 0.2). Den tilføjer RFC7807-fejlmodel, standardiserede query-parametre, headers (RateLimit, ETag, Idempotency-Key), operationIds og eksempler. + +openapi: 3.1.0 +info: + title: ModusBuild API (MVP) + version: 0.2.0 + description: Contract-first specifikation for ModusBuild v1 (MVP). Se RFC‑001 for forretningsregler. +servers: + - url: https://api.modusbuild.local + description: Local/dev +security: + - bearerAuth: [] +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + ProjectId: + in: query + name: project_id + schema: { type: string, format: uuid } + Status: + in: query + name: status + schema: { type: string, enum: [draft, for_review, approved_to_publish, published, superseded] } + Q: + in: query + name: q + schema: { type: string } + Page: + in: query + name: page + schema: { type: integer, minimum: 1, default: 1 } + PerPage: + in: query + name: per_page + schema: { type: integer, minimum: 1, maximum: 100, default: 25 } + Sort: + in: query + name: sort + description: 'fx: code,-updated_at' + schema: { type: string } + DateFrom: + in: query + name: date_from + schema: { type: string, format: date-time } + DateTo: + in: query + name: date_to + schema: { type: string, format: date-time } + TenantHeader: + in: header + name: X-Tenant + schema: { type: string } + description: Optional tenant identifier; if omitted, derived from token + headers: + RateLimit-Limit: + schema: { type: integer } + description: Requests per timevindue for klienten + RateLimit-Remaining: + schema: { type: integer } + description: Resterende requests i nuværende vindue + RateLimit-Reset: + schema: { type: integer } + description: Sekunder til reset af vindue + ETag: + schema: { type: string } + description: Entity tag til conditional GET + responses: + Problem: + description: RFC7807 Problem Details + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + Unauthorized: + description: Unauthorized + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + Forbidden: + description: Forbidden + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + NotFound: + description: Not found + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + TooManyRequests: + description: Rate limit exceeded + headers: + Retry-After: + schema: { type: integer } + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + schemas: + Pagination: + type: object + properties: + current_page: { type: integer } + per_page: { type: integer } + total: { type: integer } + Problem: + type: object + properties: + type: { type: string, format: uri } + title: { type: string } + status: { type: integer } + detail: { type: string } + instance: { type: string } + errors: + type: object + description: Valideringsfejl pr. felt + additionalProperties: + type: array + items: { type: string } + Project: + type: object + properties: + id: { type: string, format: uuid } + org_id: { type: string, format: uuid } + template_id: { type: string, format: uuid } + status: { type: string, enum: [active, archived] } + code: { type: string } + Contract: + type: object + properties: + id: { type: string, format: uuid } + offer_id: { type: string, format: uuid } + signed_at: { type: string, format: date-time, nullable: true } + Document: + type: object + properties: + id: { type: string, format: uuid } + project_id: { type: string, format: uuid } + code: { type: string } + title: { type: string } + discipline: { type: string } + phase_code: { type: string } + classification: { type: object, additionalProperties: true } + status: + type: string + enum: [draft, for_review, approved_to_publish, published, superseded] + current_version_id: { type: string, format: uuid, nullable: true } + updated_at: { type: string, format: date-time } + DocumentCreate: + type: object + required: [project_id, code, title] + properties: + project_id: { type: string, format: uuid } + code: { type: string } + title: { type: string } + discipline: { type: string } + phase_code: { type: string } + classification: { type: object, additionalProperties: true } + DocumentVersion: + type: object + properties: + id: { type: string, format: uuid } + document_id: { type: string, format: uuid } + rev: { type: string } + version: { type: string } + storage_key: { type: string } + mime: { type: string } + size: { type: integer } + approved_at: { type: string, format: date-time, nullable: true } + +paths: + /api/v1/documents: + get: + operationId: listDocuments + summary: List documents + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/Status' + - $ref: '#/components/parameters/Q' + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PerPage' + - $ref: '#/components/parameters/Sort' + - $ref: '#/components/parameters/DateFrom' + - $ref: '#/components/parameters/DateTo' + responses: + '200': + description: OK + headers: + RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' } + RateLimit-Remaining: { $ref: '#/components/headers/RateLimit-Remaining' } + RateLimit-Reset: { $ref: '#/components/headers/RateLimit-Reset' } + ETag: { $ref: '#/components/headers/ETag' } + content: + application/json: + examples: + ok: + value: + data: + - { id: 'uuid-1', code: 'A-001', title: 'Situationsplan', status: 'approved_to_publish' } + meta: { current_page: 1, per_page: 25, total: 120 } + schema: + type: object + properties: + data: + type: array + items: { $ref: '#/components/schemas/Document' } + meta: { $ref: '#/components/schemas/Pagination' } + '401': { $ref: '#/components/responses/Unauthorized' } + '429': { $ref: '#/components/responses/TooManyRequests' } + post: + operationId: createDocument + summary: Create document (metadata) + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/DocumentCreate' } + examples: + create: + value: { project_id: 'uuid', code: 'A-010', title: 'Facader', discipline: 'A', phase_code: 'A4' } + parameters: + - in: header + name: Idempotency-Key + required: false + schema: { type: string } + description: Unik nøgle for at gøre POST idempotent + responses: + '201': + description: Created + content: + application/json: + schema: { $ref: '#/components/schemas/Document' } + '400': { $ref: '#/components/responses/Problem' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Problem' } + + /api/v1/documents/{id}: + get: + operationId: getDocument + summary: Get document by id + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: OK + headers: + ETag: { $ref: '#/components/headers/ETag' } + content: + application/json: + schema: { $ref: '#/components/schemas/Document' } + '304': { description: Not Modified } + '404': { $ref: '#/components/responses/NotFound' } + + /api/v1/documents/{id}/versions: + post: + operationId: initVersionUpload + summary: Init new version upload (presigned URL) + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + - in: header + name: Idempotency-Key + required: false + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [mime, size] + properties: + mime: { type: string } + size: { type: integer, minimum: 1 } + examples: + init: + value: { mime: 'application/pdf', size: 1048576 } + responses: + '200': + description: Upload details + content: + application/json: + schema: + type: object + properties: + upload: + type: object + properties: + url: { type: string, format: uri } + fields: { type: object, additionalProperties: true } + version: { $ref: '#/components/schemas/DocumentVersion' } + '401': { $ref: '#/components/responses/Unauthorized' } + '409': { $ref: '#/components/responses/Problem' } + + /api/v1/document-versions/{id}/approve: + post: + operationId: approveVersion + summary: Approve a version + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: Approved + content: + application/json: + schema: { $ref: '#/components/schemas/Document' } + examples: + ok: + value: { id: 'uuid', status: 'approved_to_publish', current_version_id: 'uuid-v' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '409': { $ref: '#/components/responses/Problem' } + + /api/v1/transmittals: + get: + operationId: listTransmittals + summary: List transmittals + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PerPage' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/Transmittal' } } + meta: { $ref: '#/components/schemas/Pagination' } + post: + operationId: createTransmittal + summary: Create transmittal + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/TransmittalCreate' } + responses: + '201': { description: Created } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Problem' } + + /api/v1/projects: + get: + operationId: listProjects + summary: List projects + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PerPage' + - $ref: '#/components/parameters/Sort' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/Project' } } + meta: { $ref: '#/components/schemas/Pagination' } + + /api/v1/projects/{id}/gates/{gateId}/request-review: + post: + operationId: requestGateReview + summary: Request gate review/decision + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + - in: path + name: gateId + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: Decision + content: + application/json: + schema: + type: object + properties: + result: { type: string, enum: [passed, rejected] } + decided_at: { type: string, format: date-time } + reasons: { type: array, items: { type: string } } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Problem' } + + /api/v1/offers/{id}/accept: + post: + operationId: acceptOffer + summary: Accept offer → create contract and project + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + - in: header + name: Idempotency-Key + required: false + schema: { type: string } + responses: + '200': + description: Accepted + content: + application/json: + schema: + type: object + properties: + contract: { $ref: '#/components/schemas/Contract' } + project: { $ref: '#/components/schemas/Project' } + '401': { $ref: '#/components/responses/Unauthorized' } + '409': { $ref: '#/components/responses/Problem' } + +webhooks: + document.version.approved: + post: + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + event: { type: string, enum: [document.version.approved] } + id: { type: string } + occurred_at: { type: string, format: date-time } + tenant_id: { type: string } + data: + type: object + properties: + document_id: { type: string, format: uuid } + version_id: { type: string, format: uuid } + approved_by: { type: string, format: uuid } + headers: + X-ModusBuild-Signature: + schema: { type: string } + description: HMAC-SHA256 signatur af request body (hex) + +--- + +## 40. Postman Collection v2.1 (auto-afledt eksempel) +> Importér JSON’en i Postman/Insomnia. Sæt `{{baseUrl}}` og `{{token}}` i miljøvariabler. Collections bør genereres fra OpenAPI i praksis – dette er en startpakke. + +```json +{ + "info": {"name": "ModusBuild v1 (MVP)", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"}, + "item": [ + { + "name": "Documents / List", + "request": { + "method": "GET", + "header": [ {"key": "Authorization", "value": "Bearer {{token}}"} ], + "url": {"raw": "{{baseUrl}}/api/v1/documents?project_id={{project_id}}&page=1&per_page=25", "host": ["{{baseUrl}}"], "path": ["api","v1","documents"], "query": [ + {"key":"project_id","value":"{{project_id}}"},{"key":"page","value":"1"},{"key":"per_page","value":"25"} + ]} + } + }, + { + "name": "Documents / Create", + "event": [{ + "listen":"prerequest","script":{"exec":["pm.variables.set('idem', Date.now()+'-'+Math.random().toString(36).slice(2));"],"type":"text/javascript"} + }], + "request": { + "method": "POST", + "header": [ {"key": "Authorization", "value": "Bearer {{token}}"}, {"key":"Idempotency-Key","value":"{{idem}}"} ], + "body": {"mode":"raw","raw":"{ + \"project_id\": \"{{project_id}}\",\n \"code\": \"A-010\",\n \"title\": \"Facader\",\n \"discipline\": \"A\",\n \"phase_code\": \"A4\"\n}"}, + "url": {"raw": "{{baseUrl}}/api/v1/documents", "host": ["{{baseUrl}}"], "path": ["api","v1","documents"]} + } + }, + { + "name": "Document Versions / Init Upload", + "event": [{ + "listen":"prerequest","script":{"exec":["pm.variables.set('idem', Date.now()+'-'+Math.random().toString(36).slice(2));"],"type":"text/javascript"} + }], + "request": { + "method": "POST", + "header": [ {"key": "Authorization", "value": "Bearer {{token}}"}, {"key":"Idempotency-Key","value":"{{idem}}"} ], + "body": {"mode":"raw","raw":"{ + \"mime\": \"application/pdf\",\n \"size\": 1048576\n}"}, + "url": {"raw": "{{baseUrl}}/api/v1/documents/{{document_id}}/versions", "host": ["{{baseUrl}}"], "path": ["api","v1","documents","{{document_id}}","versions"]} + } + }, + { + "name": "Document Versions / Approve", + "request": { + "method": "POST", + "header": [ {"key": "Authorization", "value": "Bearer {{token}}"} ], + "url": {"raw": "{{baseUrl}}/api/v1/document-versions/{{version_id}}/approve", "host": ["{{baseUrl}}"], "path": ["api","v1","document-versions","{{version_id}}","approve"]} + } + }, + { + "name": "Projects / Gates / Request review", + "request": { + "method": "POST", + "header": [ {"key": "Authorization", "value": "Bearer {{token}}"} ], + "url": {"raw": "{{baseUrl}}/api/v1/projects/{{project_id}}/gates/{{gate_id}}/request-review", "host": ["{{baseUrl}}"], "path": ["api","v1","projects","{{project_id}}","gates","{{gate_id}}","request-review"]} + } + }, + { + "name": "Offers / Accept", + "event": [{ + "listen":"prerequest","script":{"exec":["pm.variables.set('idem', Date.now()+'-'+Math.random().toString(36).slice(2));"],"type":"text/javascript"} + }], + "request": { + "method": "POST", + "header": [ {"key": "Authorization", "value": "Bearer {{token}}"}, {"key":"Idempotency-Key","value":"{{idem}}"} ], + "url": {"raw": "{{baseUrl}}/api/v1/offers/{{offer_id}}/accept", "host": ["{{baseUrl}}"], "path": ["api","v1","offers","{{offer_id}}","accept"]} + } + } + ], + "variable": [ + {"key": "baseUrl", "value": "https://api.modusbuild.local"}, + {"key": "token", "value": ""}, + {"key": "project_id", "value": ""}, + {"key": "document_id", "value": ""}, + {"key": "version_id", "value": ""}, + {"key": "gate_id", "value": ""}, + {"key": "offer_id", "value": ""} + ] +} +``` + +Brug: +1.Importér JSON’en i Postman → vælg/opsæt miljø (baseUrl, token, ids). +2.Kald Documents / Create → brug svaret til at sætte {{document_id}}. +3.Kald Document Versions / Init Upload → upload til upload.url i S3. +4.Kald Document Versions / Approve. +5.Kald Projects / Gates / Request review for at passere gate, når kriterier er opfyldt. + +⸻ + +41. QC: OpenAPI 0.2.1 – præciseringer (delta) + +Formål: Skærpe cache‑semantik, eksempler og fejlmodeller uden at genskrive hele §39. + +41.1 Globale parametre + +Tilføj denne header‑parameter til components.parameters: + +IfNoneMatch: + in: header + name: If-None-Match + schema: { type: string } + description: Returnér 304 Not Modified hvis ETag matcher + +41.2 Dokumentliste (GET /api/v1/documents) + +Patch til §39: +•Tilføj parameteren IfNoneMatch under parameters. +•Tilføj response '304': { description: Not Modified }. +•Bevar RateLimit/ETag headers på 200. + +YAML‑patch: + + /api/v1/documents: + get: + parameters: + - $ref: '#/components/parameters/IfNoneMatch' + responses: + '304': { description: Not Modified } + +41.3 Fejleksempler (Problem Details) + +Tilføj repræsentative examples under relevante responses: + +components: + responses: + Problem: + content: + application/problem+json: + examples: + validation: + value: + type: "https://modusbuild/errors/validation" + title: "Unprocessable Entity" + status: 422 + detail: "Feltvalidering fejlede" + errors: { code: ["code must be unique within project"] } + conflict: + value: + type: "https://modusbuild/errors/conflict" + title: "Conflict" + status: 409 + detail: "Version cannot be approved from current state" + +41.4 Eksempel på 409 ved approve + +Patch POST /api/v1/document-versions/{id}/approve med 409 example: + + /api/v1/document-versions/{id}/approve: + post: + responses: + '409': + content: + application/problem+json: + examples: + not_allowed: + value: + type: "https://modusbuild/errors/conflict" + title: "Conflict" + status: 409 + detail: "Only latest version can be approved" + +⸻ + +42. ETag & If-None-Match – API‑semantik +•ETag genereres for alle GET‑responses (enkeltressourcer og lister). +•Klient kan sende If-None-Match for at få 304 ved uændrede data (båndbredde og latency ned). +•Hash‑grundlag: stable JSON‑serialisering af response body eller updated_at‑maks + total count for lister. +•Caching må ikke lække across tenants; ETag indeholder implicit tenant i hash‑input. + +⸻ + +43. Databaseskema – constraints & guards (migrations) + +Formål: Fange fejl tidligt i DB‑laget og lette politikker i kode. + +43.1 Statusfelter (CHECK) + +Document.status: + +$table->string('status'); +DB::statement("ALTER TABLE documents ADD CONSTRAINT documents_status_chk CHECK (status IN ('draft','for_review','approved_to_publish','published','superseded'))"); + +Gate.status: + +$table->string('status'); +DB::statement("ALTER TABLE gates ADD CONSTRAINT gates_status_chk CHECK (status IN ('open','passed','rejected'))"); + +Offer.status: + +$table->string('status'); +DB::statement("ALTER TABLE offers ADD CONSTRAINT offers_status_chk CHECK (status IN ('draft','sent','accepted','rejected'))"); + +43.2 Unikheder & referentielle regler +•documents: UQ(project_id, code) for at sikre projektspecifik unik kode. +•document_versions: UQ(document_id, rev, version) for at undgå duplikater. +•gates: UQ(phase_id) da hver fase har præcis én gate i MVP. +•ON DELETE: document_versions.document_id → CASCADE; documents.project_id → RESTRICT (undgå slet projekt med aktive docs i MVP). + +43.3 Concurrency (optimistic locking) +•documents.locked_version INT NOT NULL DEFAULT 0. +•Controllers anvender If-Match (senere) eller DB‑check: opdatering med WHERE locked_version = :expected og inkrement ved succes. + +⸻ + +44. Publicering (published) – præcis forretningsregel + +Mål: Skelne mellem “godkendt til udgivelse” og “faktisk publiceret”. +1.Approved ≠ Published. approved_to_publish er kvalitetsstatus; publicering er en distributionshandling. +2.Standardregel (MVP): Et dokument bliver published kun når det indgår i en Transmittal, der sendes (transmittals.sent_at IS NOT NULL). +3.Konsekvenser: +•SetCurrentIfApprovedJob sætter kun current_version_id ved approve – ændrer ikke documents.status til published. +•Når en Transmittal sendes, kører MarkPublishedOnTransmittalSentJob og opdaterer alle reference‑dokumenter fra approved_to_publish → published. +•published dokumenter kan stadig blive superseded ved ny godkendt version. +4.Event: transmittal.sent (payload: { transmittal_id, project_id, document_ids[], sent_at }). +5.OpenAPI: POST /api/v1/transmittals returnerer 201 og sætter sent_at (nul hvis kladde). PATCH /api/v1/transmittals/{id}/send (kan tilføjes i P2) udløser send + event. +6.Policies: Kun roller Owner/Lead kan sende transmittals. + +Acceptance update (§17): +•“Et dokument skifter til published når (og kun når) det er inkluderet i en sendt Transmittal.” + +⸻ + +45. Eksempel‑migrations (Laravel) – uddrag + +// documents +Schema::create('documents', function (Blueprint $t) { + $t->uuid('id')->primary(); + $t->uuid('project_id'); + $t->string('code'); + $t->string('title'); + $t->string('discipline')->nullable(); + $t->string('phase_code')->nullable(); + $t->jsonb('classification_jsonb')->nullable(); + $t->string('status')->default('draft'); + $t->uuid('current_version_id')->nullable(); + $t->integer('locked_version')->default(0); + $t->timestampsTz(); + $t->unique(['project_id','code']); + $t->index(['project_id','status']); +}); +DB::statement("ALTER TABLE documents ADD CONSTRAINT documents_status_chk CHECK (status IN ('draft','for_review','approved_to_publish','published','superseded'))"); + +// document_versions +Schema::create('document_versions', function (Blueprint $t) { + $t->uuid('id')->primary(); + $t->uuid('document_id'); + $t->string('rev',8)->default('A'); + $t->string('version',8)->default('1.0'); + $t->string('storage_key')->unique(); + $t->string('checksum')->nullable(); + $t->bigInteger('size')->nullable(); + $t->string('mime',128)->nullable(); + $t->timestampTz('approved_at')->nullable(); + $t->uuid('approved_by')->nullable(); + $t->timestampsTz(); + $t->unique(['document_id','rev','version']); + $t->index(['document_id']); +}); + +// gates (unik phase) +Schema::table('gates', function (Blueprint $t) { + $t->unique('phase_id'); +}); + +⸻ + +46. QC‑tjek: konsistensliste (gennemført) +•Statusværdier i snake_case overalt. +•Gate‑afslag via 200/result: rejected + reasons. +•Unikheder: documents(project_id,code), document_versions(document_id,rev,version), gates(phase_id). +•Upload‑pipeline jobnavne afklaret. +•ETag/If-None-Match specificeret for lister og entiteter. +•Problem Details med eksempler (422/409). +•Publicering koblet til Transmittal‑send (ikke approve). + +⸻ + +47. Prism Mock – setup & brug (kontrakt‑drevet fake API) + +Mål: Køre en lokal mock af OpenAPI.yaml (rev 0.2.1) så frontend/QA kan bygge og teste uden backend. + +47.1 Installation + +# i projektets rod (hvor openapi.yaml ligger) +npm init -y +npm i -D @stoplight/prism-cli + +Tilføj scripts i package.json: + +{ + "scripts": { + "mock:api": "prism mock openapi.yaml -p 4010", + "mock:api:dynamic": "prism mock openapi.yaml -p 4010 -d" + } +} + +•mock:api svarer med faste eksempler fra kontrakten. +•mock:api:dynamic genererer realistiske værdier (randomiseret) ud fra skemaer. + +Kør: + +npm run mock:api +# Server kører på http://localhost:4010 + +47.2 Hurtigtest (cURL) + +Husk Authorization header (Bearer), da kontrakten kræver det. + +# List documents +curl -s \ + -H "Authorization: Bearer test-token" \ + "http://localhost:4010/api/v1/documents?project_id=11111111-1111-1111-1111-111111111111&page=1&per_page=10" + +# Create document (idempotent POST med nøgle) +curl -s -X POST \ + -H "Authorization: Bearer test-token" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: demo-$(date +%s)" \ + -d '{"project_id":"11111111-1111-1111-1111-111111111111","code":"A-010","title":"Facader","discipline":"A","phase_code":"A4"}' \ + http://localhost:4010/api/v1/documents + +# Init upload for version +curl -s -X POST \ + -H "Authorization: Bearer test-token" \ + -H "Content-Type: application/json" \ + -d '{"mime":"application/pdf","size":1048576}' \ + http://localhost:4010/api/v1/documents/22222222-2222-2222-2222-222222222222/versions + +# Approve version +curl -s -X POST \ + -H "Authorization: Bearer test-token" \ + http://localhost:4010/api/v1/document-versions/33333333-3333-3333-3333-333333333333/approve + +# Request gate review +curl -s -X POST \ + -H "Authorization: Bearer test-token" \ + http://localhost:4010/api/v1/projects/44444444-4444-4444-4444-444444444444/gates/55555555-5555-5555-5555-555555555555/request-review + +47.3 Vite proxy (frontend uden CORS‑problemer) + +I vite.config.ts: + +import { defineConfig } from 'vite' +import laravel from 'laravel-vite-plugin' + +export default defineConfig({ + plugins: [laravel({ input: ['resources/js/app.js'], refresh: true })], + server: { + proxy: { + '/api': 'http://localhost:4010' + } + } +}) + +•Frontend kan nu kalde /api/v1/... direkte; dev‑server videresender til Prism. + +47.4 Tips +•Static vs dynamic mock: Brug mock:api til stabile snaps og mock:api:dynamic når du vil strese UI’et med varierende data. +•ETag/304: Klient kan sende If-None-Match som i kontrakten; Prism svarer 304 når muligt i forhold til examples. +•Idempotency-Key: God vane at sende på POST i dev også (matches produktion). +•Webhooks: Mockes separat (fx med en lille local server eller RequestBin) – Prism mocker ikke udgående kald. + +47.5 Næste skridt +•Generér Postman/Insomnia direkte fra openapi.yaml, peg {{baseUrl}} på http://localhost:4010. +•Når UI‑skeletter er klar: begynd at erstatte Prism‑kald med rigtige endpoints i backend branch, men behold mock til hurtige UI‑tests. + diff --git a/lang/en/auth.php b/lang/en/auth.php new file mode 100644 index 0000000..98e981f --- /dev/null +++ b/lang/en/auth.php @@ -0,0 +1,7 @@ + 'These credentials do not match our records.', + 'password' => 'The provided password is incorrect.', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', +]; diff --git a/lang/en/pagination.php b/lang/en/pagination.php new file mode 100644 index 0000000..4172d54 --- /dev/null +++ b/lang/en/pagination.php @@ -0,0 +1,6 @@ + '« Previous', + 'next' => 'Next »', +]; diff --git a/lang/en/passwords.php b/lang/en/passwords.php new file mode 100644 index 0000000..65a2695 --- /dev/null +++ b/lang/en/passwords.php @@ -0,0 +1,9 @@ + 'Your password has been reset.', + 'sent' => 'We have emailed your password reset link.', + 'throttled' => 'Please wait before retrying.', + 'token' => 'This password reset token is invalid.', + 'user' => "We can't find a user with that email address.", +]; diff --git a/lang/en/validation.php b/lang/en/validation.php new file mode 100644 index 0000000..44d0340 --- /dev/null +++ b/lang/en/validation.php @@ -0,0 +1,40 @@ + 'The :attribute must be accepted.', + 'active_url' => 'The :attribute is not a valid URL.', + 'after' => 'The :attribute must be a date after :date.', + 'alpha' => 'The :attribute may only contain letters.', + 'alpha_dash' => 'The :attribute may only contain letters, numbers, dashes and underscores.', + 'alpha_num' => 'The :attribute may only contain letters and numbers.', + 'array' => 'The :attribute must be an array.', + 'before' => 'The :attribute must be a date before :date.', + 'between' => [ + 'numeric' => 'The :attribute must be between :min and :max.', + 'file' => 'The :attribute must be between :min and :max kilobytes.', + 'string' => 'The :attribute must be between :min and :max characters.', + 'array' => 'The :attribute must have between :min and :max items.', + ], + 'boolean' => 'The :attribute field must be true or false.', + 'confirmed' => 'The :attribute confirmation does not match.', + 'date' => 'The :attribute is not a valid date.', + 'email' => 'The :attribute must be a valid email address.', + 'integer' => 'The :attribute must be an integer.', + 'max' => [ + 'numeric' => 'The :attribute may not be greater than :max.', + 'file' => 'The :attribute may not be greater than :max kilobytes.', + 'string' => 'The :attribute may not be greater than :max characters.', + 'array' => 'The :attribute may not have more than :max items.', + ], + 'min' => [ + 'numeric' => 'The :attribute must be at least :min.', + 'file' => 'The :attribute must be at least :min kilobytes.', + 'string' => 'The :attribute must be at least :min characters.', + 'array' => 'The :attribute must have at least :min items.', + ], + 'required' => 'The :attribute field is required.', + 'unique' => 'The :attribute has already been taken.', + 'url' => 'The :attribute format is invalid.', + + 'attributes' => [], +]; diff --git a/package.json b/package.json new file mode 100644 index 0000000..e719b0a --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "modusbuild", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "devDependencies": { + "@tailwindcss/forms": "^0.5.7", + "alpinejs": "^3.13.5", + "autoprefixer": "^10.4.16", + "axios": "^1.6.7", + "laravel-vite-plugin": "^1.0.0", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "typescript": "^5.2.2", + "vite": "^5.0.0" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..ccb90d7 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,20 @@ + + + + + ./tests/Unit + + + ./tests/Feature + + + + + ./app + + + diff --git a/postcss.config.cjs b/postcss.config.cjs new file mode 100644 index 0000000..67cdf1a --- /dev/null +++ b/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..4150edf --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,11 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..58bac35 --- /dev/null +++ b/public/index.php @@ -0,0 +1,17 @@ +make(Illuminate\Contracts\Http\Kernel::class); + +$response = $kernel->handle( + $request = Illuminate\Http\Request::capture() +); + +$response->send(); + +$kernel->terminate($request, $response); diff --git a/resources/css/app.css b/resources/css/app.css new file mode 100644 index 0000000..7b5d4a5 --- /dev/null +++ b/resources/css/app.css @@ -0,0 +1,13 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: light dark; +} + +body { + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background-color: #f8fafc; + color: #0f172a; +} diff --git a/resources/js/app.ts b/resources/js/app.ts new file mode 100644 index 0000000..8f44954 --- /dev/null +++ b/resources/js/app.ts @@ -0,0 +1,4 @@ +import './bootstrap'; +import '../css/app.css'; + +console.info('ModusBuild frontend bootstrap loaded'); diff --git a/resources/js/bootstrap.ts b/resources/js/bootstrap.ts new file mode 100644 index 0000000..a43155d --- /dev/null +++ b/resources/js/bootstrap.ts @@ -0,0 +1,10 @@ +import axios from 'axios'; + +declare global { + interface Window { + axios: typeof axios; + } +} + +window.axios = axios; +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php new file mode 100644 index 0000000..1b829df --- /dev/null +++ b/resources/views/dashboard.blade.php @@ -0,0 +1,275 @@ + + + + + + ModusBuild · Project cockpit (prototype) + @vite(['resources/js/app.ts']) + + + @php + $projects = [ + ['code' => 'PRJ-2025-001', 'name' => 'Rækkehuse – Fase A4', 'status' => 'Active', 'phase' => 'A4 · Forprojektering', 'progress' => 62], + ['code' => 'PRJ-2025-002', 'name' => 'Nyt domicil – Design review', 'status' => 'Gate review', 'phase' => 'A5 · Myndighed', 'progress' => 38], + ['code' => 'PRJ-2024-019', 'name' => 'Renovering – Udbud', 'status' => 'On track', 'phase' => 'B1 · Udbud', 'progress' => 81], + ]; + + $gateChecklist = [ + ['title' => 'Dokumenter godkendt', 'value' => '12 / 12', 'state' => 'done'], + ['title' => 'Ekstern godkendelse', 'value' => 'Ventende', 'state' => 'pending'], + ['title' => 'Workflow trin åbne', 'value' => '1 aktivitet', 'state' => 'warning'], + ]; + + $documents = [ + ['code' => 'A-010', 'title' => 'Facader', 'discipline' => 'A', 'status' => 'For review', 'updated' => '3 timer siden'], + ['code' => 'S-021', 'title' => 'Statik – dæk', 'discipline' => 'S', 'status' => 'Approved', 'updated' => 'I går 14:12'], + ['code' => 'V-004', 'title' => 'VVS oversigt', 'discipline' => 'V', 'status' => 'Changes requested', 'updated' => 'I går 09:31'], + ['code' => 'E-112', 'title' => 'El schema – plan 2', 'discipline' => 'E', 'status' => 'Draft', 'updated' => '4 dage siden'], + ]; + + $activityFeed = [ + ['time' => '10:24', 'actor' => 'Julie Madsen', 'action' => 'godkendte version 1.1 af “Statik – dæk”', 'type' => 'success'], + ['time' => '09:10', 'actor' => 'Ekstern reviewer', 'action' => 'anmodede om ændringer i “VVS oversigt”', 'type' => 'warning'], + ['time' => 'I går', 'actor' => 'System', 'action' => 'sendte transmittal TR-045 til Bygherre', 'type' => 'info'], + ['time' => 'I går', 'actor' => 'Martin Feldt', 'action' => 'startede gate review for Fase A5', 'type' => 'default'], + ]; + @endphp + +
+
+
+
+
+

ModusBuild prototype

+

Projektcockpit

+

Visualiseret ud fra RFC-001: gate readiness, dokumentstatus og seneste aktiviteter.

+
+
+ + + Sandbox environment + +
+ + Sofie Holm + Lead Engineer + + SH +
+
+
+
+
+ +
+ + +
+
+ @foreach ($projects as $project) +
+
+
+

{{ $project['code'] }}

+

{{ $project['name'] }}

+

{{ $project['phase'] }}

+
+ {{ $project['status'] }} +
+
+
+ Fremdrift + {{ $project['progress'] }}% +
+
+ +
+
+
+ @endforeach +
+ +
+
+
+
+

Gate readiness

+ Fase A5 +
+

Status baseret på GateService::validate – kriterier fra RFC-001 §8.2.

+
    + @foreach ($gateChecklist as $item) +
  • +
    +

    {{ $item['title'] }}

    +

    {{ $item['state'] === 'pending' ? 'Afventer ekstern handling' : 'Opdateret for 2 minutter siden' }}

    +
    + {{ $item['value'] }} +
  • + @endforeach +
+ +
+ +
+
+

Dokumenter i fokus

+ Se alle +
+
+ + + + + + + + + + + + @foreach ($documents as $doc) + + + + + + + + @endforeach + +
KodeTitelDisciplinStatusOpdateret
{{ $doc['code'] }}{{ $doc['title'] }} + {{ $doc['discipline'] }} + + @php + $statusColours = [ + 'Approved' => 'bg-emerald-100 text-emerald-700', + 'For review' => 'bg-amber-100 text-amber-700', + 'Draft' => 'bg-slate-100 text-slate-600', + 'Changes requested' => 'bg-rose-100 text-rose-700', + ]; + @endphp + {{ $doc['status'] }} + {{ $doc['updated'] }}
+
+
+
+ +
+
+

Aktiviteter

+

Audit- og eventfeed inspireret af RFC-001 §5 og §24.

+
    + @foreach ($activityFeed as $event) +
  1. + +
    +

    {{ $event['actor'] }} {{ $event['action'] }}

    +

    {{ $event['time'] }}

    +
    +
  2. + @endforeach +
+
+ +
+

Næste skridt

+

Plan for prototype → produkt:

+
    +
  • + + Tilføj live-data via Inertia + API v1 endpoints. +
  • +
  • + + Udvid dokumentlisten med filtrering og søgning. +
  • +
  • + + Integrér gate beslutninger og transmittal events. +
  • +
+ +
+
+
+
+
+
+ + diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php new file mode 100644 index 0000000..28ebcef --- /dev/null +++ b/resources/views/welcome.blade.php @@ -0,0 +1,25 @@ + + + + + + ModusBuild + @vite(['resources/js/app.ts']) + + +
+
+ + RFC-001 Aligned MVP + +
+

ModusBuild monolith bootstrap

+

+ Baseline Laravel environment scaffolding for documents, process, and sales modules. +

+ + Læs RFC-001 + +
+ + diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..9b74e98 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,11 @@ +name('api.health'); + +Route::middleware('auth:sanctum')->get('/user', function (Request $request) { + return $request->user(); +}); diff --git a/routes/console.php b/routes/console.php new file mode 100644 index 0000000..3c9adf1 --- /dev/null +++ b/routes/console.php @@ -0,0 +1,8 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..2ce44b9 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,5 @@ +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + + RefreshDatabaseState::setRefreshDatabaseDefaultConnection(null); + + return $app; + } +} diff --git a/tests/Feature/HealthCheckTest.php b/tests/Feature/HealthCheckTest.php new file mode 100644 index 0000000..398d380 --- /dev/null +++ b/tests/Feature/HealthCheckTest.php @@ -0,0 +1,15 @@ +getJson('/api/health'); + + $response->assertOk() + ->assertJsonStructure([ + 'status', + 'timestamp', + ]); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..b6b3259 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,7 @@ +in('Feature'); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..2932d4a --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,10 @@ +validate($gate, $documents, [], []); + + expect($result['ok'])->toBeFalse() + ->and($result['reasons'])->toContain('All documents in the phase must be approved_to_publish.'); +}); + +it('prevents gate when external approval missing', function () { + $service = new GateService(); + $gate = new Gate('gate-1', 'project-1', 'phase-1', 'open'); + $documents = [ + new Document('doc-1', 'project-1', 'A-001', 'Situationsplan', 'C', 'A4', [], 'approved_to_publish', 'v1'), + ]; + + $result = $service->validate($gate, $documents, [], []); + + expect($result['ok'])->toBeFalse() + ->and($result['reasons'])->toContain('External approval is required before passing the gate.'); +}); + +it('allows gate when requirements met', function () { + $service = new GateService(); + $gate = new Gate('gate-1', 'project-1', 'phase-1', 'open'); + $documents = [ + new Document('doc-1', 'project-1', 'A-001', 'Situationsplan', 'C', 'A4', [], 'approved_to_publish', 'v1'), + ]; + $approvals = [ + new ExternalApproval('ext-1', 'phase-1', ['email' => 'approver@example.com'], new DateTimeImmutable()), + ]; + + $result = $service->validate($gate, $documents, $approvals, []); + + expect($result['ok'])->toBeTrue() + ->and($result['reasons'])->toBeEmpty(); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c10df04 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Node", + "strict": true, + "jsx": "preserve", + "baseUrl": "./", + "types": ["vite/client"] + }, + "include": [ + "resources/js/**/*.ts", + "resources/js/**/*.tsx" + ] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..7965404 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/js/app.ts'], + refresh: true, + }), + ], + server: { + host: '0.0.0.0', + port: 5173, + proxy: { + '/api': 'http://localhost:8080' + } + } +}); From 87112e02e8fbeeb1d39745f972d9f9f8312dc58b Mon Sep 17 00:00:00 2001 From: "Daniel D." Date: Tue, 4 Nov 2025 11:54:36 +0100 Subject: [PATCH 2/2] docs: add merge conflict resolution guide --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index 2d03994..999dde9 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,34 @@ Monolitten for ModusBuilds MVP er bygget på Laravel 11 med Vue/Inertia frontend ## Dokumentation - [RFC-001: ModusBuild – MVP teknisk specifikation (Draft)](docs/rfcs/RFC-001-modusbuild-mvp.md) + +## Håndtering af GitHub-konflikter + +Hvis GitHub viser en besked om konflikter, når du forsøger at merge en pull request, kan du løse dem lokalt med følgende fremgangsmåde: + +1. Sørg for, at din lokale `main` (eller den branch, du vil merge ind i) er opdateret: + ```bash + git checkout main + git fetch origin + git pull origin main + ``` +2. Skift tilbage til din feature-branch (f.eks. `work`) og merge de seneste ændringer fra `main` ind: + ```bash + git checkout work + git merge origin/main + ``` + Git markerer nu de filer, der er i konflikt (fx `Makefile`, `composer.json`, `routes/web.php`). +3. Åbn hver konfliktfil i din editor og fjern konfliktmarkeringerne (`<<<<<<<`, `=======`, `>>>>>>>`) ved at vælge, kombinere eller omskrive indholdet, så det afspejler den ønskede endelige version. +4. Når alle konflikter er løst, stage filerne og fuldfør mergingen: + ```bash + git add Makefile bootstrap/cache/.gitignore composer.json config/database.php routes/web.php storage/.gitignore + git commit + ``` + Hvis merge-committen allerede blev oprettet automatisk, kan du nøjes med `git commit` for at afslutte den. +5. Afslut ved at pushe den opdaterede branch til GitHub: + ```bash + git push origin work + ``` +6. Gå tilbage til pull requesten på GitHub og verificér, at konflikten er væk. Herefter kan du fortsætte med review og merge. + +> Tip: Hvis du foretrækker rebase-fremgangsmåden, kan du erstatte trin 2 med `git rebase origin/main` og afslutte eventuelle konflikter trin for trin. Husk at pushe med `--force-with-lease`, hvis du rebaser.