TenantAtlas/apps/platform/app/Services/Onboarding/OnboardingDraftMutationService.php
ahmido ce0615a9c1 Spec 182: relocate Laravel platform to apps/platform (#213)
## Summary
- move the Laravel application into `apps/platform` and keep the repository root for orchestration, docs, and tooling
- update the local command model, Sail/Docker wiring, runtime paths, and ignore rules around the new platform location
- add relocation quickstart/contracts plus focused smoke coverage for bootstrap, command model, routes, and runtime behavior

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PlatformRelocation`
- integrated browser smoke validated `/up`, `/`, `/admin`, `/admin/choose-workspace`, and tenant route semantics for `200`, `403`, and `404`

## Remaining Rollout Checks
- validate Dokploy build context and working-directory assumptions against the new `apps/platform` layout
- confirm web, queue, and scheduler processes all start from the expected working directory in staging/production
- verify no legacy volume mounts or asset-publish paths still point at the old root-level `public/` or `storage/` locations

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #213
2026-04-08 08:40:47 +00:00

184 lines
6.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Onboarding;
use App\Exceptions\Onboarding\OnboardingDraftConflictException;
use App\Exceptions\Onboarding\OnboardingDraftImmutableException;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class OnboardingDraftMutationService
{
public function __construct(
private readonly OnboardingLifecycleService $lifecycleService,
) {}
/**
* @param callable(TenantOnboardingSession):void $mutator
*/
public function createOrResume(
Workspace $workspace,
User $actor,
string $entraTenantId,
callable $mutator,
?TenantOnboardingSession $preferredDraft = null,
?int $expectedVersion = null,
bool $incrementVersion = true,
?bool &$wasCreated = null,
): TenantOnboardingSession {
return DB::transaction(function () use ($workspace, $actor, $entraTenantId, $mutator, $preferredDraft, $expectedVersion, $incrementVersion, &$wasCreated): TenantOnboardingSession {
$draft = $this->resolveDraftForIdentity($workspace, $entraTenantId, $preferredDraft);
$isNew = ! $draft instanceof TenantOnboardingSession;
$wasCreated = $isNew;
if ($isNew) {
$draft = new TenantOnboardingSession;
$draft->workspace_id = (int) $workspace->getKey();
$draft->entra_tenant_id = $entraTenantId;
$draft->started_by_user_id = (int) $actor->getKey();
$draft->version = 0;
} elseif ($expectedVersion !== null) {
$this->assertExpectedVersion($draft, $expectedVersion);
}
$draft->entra_tenant_id = $entraTenantId;
$draft->updated_by_user_id = (int) $actor->getKey();
$mutator($draft);
$this->persistDraft(
draft: $draft,
incrementVersion: $incrementVersion || $isNew,
);
return $draft->refresh();
});
}
/**
* @param callable(TenantOnboardingSession):void $mutator
*/
public function mutate(
TenantOnboardingSession $draft,
User $actor,
callable $mutator,
?int $expectedVersion = null,
bool $incrementVersion = true,
bool $allowTerminal = false,
): TenantOnboardingSession {
return DB::transaction(function () use ($draft, $actor, $mutator, $expectedVersion, $incrementVersion, $allowTerminal): TenantOnboardingSession {
$lockedDraft = TenantOnboardingSession::query()
->whereKey($draft->getKey())
->lockForUpdate()
->firstOrFail();
if (! $allowTerminal && $lockedDraft->lifecycleState()->isTerminal()) {
throw new OnboardingDraftImmutableException(
draftId: (int) $lockedDraft->getKey(),
lifecycleState: $lockedDraft->lifecycleState(),
);
}
if ($expectedVersion !== null) {
$this->assertExpectedVersion($lockedDraft, $expectedVersion);
}
$lockedDraft->updated_by_user_id = (int) $actor->getKey();
$mutator($lockedDraft);
$this->persistDraft(
draft: $lockedDraft,
incrementVersion: $incrementVersion,
);
return $lockedDraft->refresh();
});
}
public function lockForTrustedMutation(TenantOnboardingSession|int|string $draft, Workspace $workspace): TenantOnboardingSession
{
$draftId = $draft instanceof TenantOnboardingSession
? (int) $draft->getKey()
: (int) $draft;
$lockedDraft = TenantOnboardingSession::query()
->whereKey($draftId)
->where('workspace_id', (int) $workspace->getKey())
->lockForUpdate()
->first();
if (! $lockedDraft instanceof TenantOnboardingSession) {
throw new NotFoundHttpException;
}
return $lockedDraft;
}
private function resolveDraftForIdentity(
Workspace $workspace,
string $entraTenantId,
?TenantOnboardingSession $preferredDraft = null,
): ?TenantOnboardingSession {
if ($preferredDraft instanceof TenantOnboardingSession) {
$lockedPreferredDraft = TenantOnboardingSession::query()
->whereKey($preferredDraft->getKey())
->where('workspace_id', (int) $workspace->getKey())
->lockForUpdate()
->first();
if ($lockedPreferredDraft instanceof TenantOnboardingSession && $lockedPreferredDraft->entra_tenant_id === $entraTenantId) {
if ($lockedPreferredDraft->lifecycleState()->isTerminal()) {
throw new OnboardingDraftImmutableException(
draftId: (int) $lockedPreferredDraft->getKey(),
lifecycleState: $lockedPreferredDraft->lifecycleState(),
);
}
return $lockedPreferredDraft;
}
}
return TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('entra_tenant_id', $entraTenantId)
->resumable()
->orderByDesc('updated_at')
->lockForUpdate()
->first();
}
private function assertExpectedVersion(TenantOnboardingSession $draft, int $expectedVersion): void
{
$actualVersion = max(1, (int) ($draft->version ?? 1));
if ($expectedVersion === $actualVersion) {
return;
}
throw new OnboardingDraftConflictException(
draftId: (int) $draft->getKey(),
expectedVersion: $expectedVersion,
actualVersion: $actualVersion,
);
}
private function persistDraft(TenantOnboardingSession $draft, bool $incrementVersion): void
{
$currentVersion = max(0, (int) ($draft->version ?? 0));
$draft->version = $incrementVersion
? $currentVersion + 1
: max(1, $currentVersion);
$this->lifecycleService->applySnapshot($draft, false);
$draft->save();
}
}