Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .cursor/rules/08-symfony-services.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
description: "Symfony service container, autowiring, config/services.yaml. Use when adding or changing services, interfaces, or DI configuration."
globs: ["config/services.yaml", "config/**/services*.yaml", "src/**/*.php"]
alwaysApply: false
---

# Symfony Services and Autowiring

**Reference**: See [Symfony Autowiring](https://symfony.com/doc/current/service_container/autowiring.html).

## Interface Bindings

**Do not add explicit interface → implementation bindings in `config/services.yaml` when:**

- The interface has **exactly one** concrete implementation in the application, and
- That implementation is in the autowired resource path (e.g. `App\` → `../src/`).

Symfony's autowiring will automatically inject the single implementation when a type hint asks for the interface. Adding a redundant `InterfaceName: class: ImplementationName` entry is unnecessary and should be avoided.

**Add explicit configuration only when:**

- The implementation lives outside the autowired resource (e.g. in a third-party bundle or vendor), or
- You need to pass specific constructor arguments, or
- There are multiple implementations and you must choose one (or use tags/compiler passes), or
- You need an alias to a decorator or a specific service id.

## When in Doubt

Prefer omitting the binding and relying on autowiring; if the container fails with "multiple implementations" or "no service for interface", then add the minimal explicit configuration needed.
4 changes: 0 additions & 4 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,6 @@ services:
App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacadeInterface:
class: App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacade

# Domain service bindings
App\WorkspaceMgmt\Domain\Service\WorkspaceStatusGuardInterface:
class: App\WorkspaceMgmt\Domain\Service\WorkspaceStatusGuard

# Infrastructure adapter bindings
App\WorkspaceMgmt\Infrastructure\Adapter\GitAdapterInterface:
class: App\WorkspaceMgmt\Infrastructure\Adapter\GitCliAdapter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ When a user wants to start a conversation on a project, and there is a workspace
- The workspace is set into status IN_SETUP
- The local workspace folder is removed
- The git repository mapped to the project that is mapped to the workspace is cloned into the local workspace folder using the github token of the project
- A new branch, whose name has the format `<abbreviated-workspace-id>-<date-and-time>`, is created from the main branch
- A new branch, whose name has the format `<YYYY-MM-DD_HH-MM-SS>-usermailATdomainDOTtld-SHORTWORKSPACEID` (timestamp with underscore/hyphens for URL/shell safety, sanitized user email, short workspace ID), is created from the main branch
- The workspace is set into status AVAILABLE_FOR_CONVERSATION

When a user wants to start a conversation on a project, and succeeds to do so because its workspace is in status AVAILABLE_FOR_CONVERSATION, then
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ public function startConversation(

try {
// Dispatch async setup if needed - this will start setup in background
$workspace = $this->workspaceMgmtFacade->dispatchSetupIfNeeded($projectId);
$workspace = $this->workspaceMgmtFacade->dispatchSetupIfNeeded($projectId, $accountInfo->email);

// If setup was dispatched (workspace is now IN_SETUP), show waiting page
if ($workspace->status === WorkspaceStatus::IN_SETUP) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,12 @@
<div class="max-w-7xl mx-auto">
{% block content %}{% endblock %}
</div>
<div class="max-w-7xl mx-auto mt-8 pt-6 border-t border-dark-200 dark:border-dark-700 text-center">
<p class="text-xs text-dark-500 dark:text-dark-400">
Sponsored by
<a href="https://www.joboo.de" class="inline-flex items-center align-middle hover:opacity-80 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-dark-800 rounded ml-1" rel="noopener noreferrer" target="_blank" aria-label="JOBOO! GmbH">
<img src="https://webassets.cdn.www.joboo.de/assets/images/joboo-inverted.png" alt="JOBOO! GmbH" class="h-4 w-auto inline-block" width="2570" height="843" loading="lazy">
</a>
</p>
</div>
{% endblock %}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Organization\Domain\SymfonyEventSubscriber;

use App\Account\Facade\AccountFacadeInterface;
use App\Account\Facade\SymfonyEvent\AccountCoreCreatedSymfonyEvent;
use App\Organization\Domain\Service\OrganizationDomainServiceInterface;
use App\Organization\Facade\SymfonyEvent\CurrentlyActiveOrganizationChangedSymfonyEvent;
Expand All @@ -12,6 +13,7 @@
use App\WorkspaceMgmt\Facade\WorkspaceMgmtFacadeInterface;
use Exception;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Throwable;
Expand All @@ -25,7 +27,8 @@ public function __construct(
private PrefabFacadeInterface $prefabFacade,
private ProjectMgmtFacadeInterface $projectMgmtFacade,
private WorkspaceMgmtFacadeInterface $workspaceMgmtFacade,
private LoggerInterface $logger
private AccountFacadeInterface $accountFacade,
private LoggerInterface $logger,
) {
}

Expand All @@ -46,11 +49,18 @@ public function handle(
)
);

$accountInfo = $this->accountFacade->getAccountInfoById($event->accountCoreId);
if ($accountInfo === null) {
throw new RuntimeException(
'Account not found for workspace setup; accountCoreId: ' . $event->accountCoreId
);
}

$prefabs = $this->prefabFacade->loadPrefabs();
foreach ($prefabs as $prefab) {
try {
$projectId = $this->projectMgmtFacade->createProjectFromPrefab($organization->getId(), $prefab);
$this->workspaceMgmtFacade->dispatchSetupIfNeeded($projectId);
$this->workspaceMgmtFacade->dispatchSetupIfNeeded($projectId, $accountInfo->email);
} catch (Throwable $e) {
$this->logger->warning('Prefab project creation failed', [
'organization_id' => $organization->getId(),
Expand Down
39 changes: 39 additions & 0 deletions src/WorkspaceMgmt/Domain/Service/BranchNameGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace App\WorkspaceMgmt\Domain\Service;

use EnterpriseToolingForSymfony\SharedBundle\DateAndTime\Service\DateAndTimeService;

use function mb_strtolower;
use function mb_substr;
use function str_replace;

/**
* Generates human-friendly workspace branch names.
*
* Format: <YYYY-MM-DD_HH-MM-SS>-usermailATdomainDOTtld-SHORTWORKSPACEID
* (underscore and hyphens only in timestamp for URL/shell safety)
*/
final class BranchNameGenerator implements BranchNameGeneratorInterface
{
public function generate(string $workspaceId, string $userEmail): string
{
$timestamp = DateAndTimeService::getDateTimeImmutable()->format('Y-m-d_H-i-s');
$sanitized = $this->sanitizeEmailForBranchName($userEmail);
$shortId = mb_substr($workspaceId, 0, 8);

return $timestamp . '-' . $sanitized . '-' . $shortId;
}

/**
* Sanitize email for use in branch name: @ → AT, . → DOT, lowercase.
*/
public function sanitizeEmailForBranchName(string $email): string
{
$lower = mb_strtolower($email, 'UTF-8');

return str_replace(['@', '.'], ['AT', 'DOT'], $lower);
}
}
16 changes: 16 additions & 0 deletions src/WorkspaceMgmt/Domain/Service/BranchNameGeneratorInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace App\WorkspaceMgmt\Domain\Service;

/**
* Generates human-friendly workspace branch names.
*/
interface BranchNameGeneratorInterface
{
/**
* Generate a branch name in format: &lt;YYYY-MM-DD_HH-MM-SS&gt;-usermailATdomainDOTtld-SHORTWORKSPACEID (URL/shell safe).
*/
public function generate(string $workspaceId, string $userEmail): string;
}
22 changes: 7 additions & 15 deletions src/WorkspaceMgmt/Domain/Service/WorkspaceSetupService.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,11 @@
use App\WorkspaceMgmt\Infrastructure\SetupSteps\ProjectSetupStepsRegistryInterface;
use App\WorkspaceMgmt\Infrastructure\SetupSteps\SetupStepsExecutorInterface;
use Doctrine\ORM\EntityManagerInterface;
use EnterpriseToolingForSymfony\SharedBundle\DateAndTime\Service\DateAndTimeService;
use Exception;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Throwable;

use function mb_substr;

/**
* Orchestrates workspace setup:
* - Remove existing folder
Expand All @@ -40,6 +37,7 @@ public function __construct(
private readonly WorkspaceStatusGuardInterface $statusGuard,
private readonly ProjectSetupStepsRegistryInterface $setupStepsRegistry,
private readonly SetupStepsExecutorInterface $setupStepsExecutor,
private readonly BranchNameGeneratorInterface $branchNameGenerator,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
Expand All @@ -51,8 +49,10 @@ public function __construct(
*
* Can be called when workspace is already IN_SETUP (async flow where facade
* already transitioned the status before dispatching the setup message).
*
* @param string $userEmail email of the user who triggered setup (used for human-friendly branch name)
*/
public function setup(Workspace $workspace): void
public function setup(Workspace $workspace, string $userEmail): void
{
$workspaceId = $workspace->getId();
if ($workspaceId === null) {
Expand All @@ -77,7 +77,7 @@ public function setup(Workspace $workspace): void
}

try {
$this->performSetup($workspace);
$this->performSetup($workspace, $userEmail);

// Transition to AVAILABLE_FOR_CONVERSATION
$workspace->setStatus(WorkspaceStatus::AVAILABLE_FOR_CONVERSATION);
Expand Down Expand Up @@ -109,7 +109,7 @@ public function getWorkspacePath(Workspace $workspace): string
return $this->workspaceRoot . '/' . $workspace->getId();
}

private function performSetup(Workspace $workspace): void
private function performSetup(Workspace $workspace, string $userEmail): void
{
$workspaceId = $workspace->getId();
$workspacePath = $this->getWorkspacePath($workspace);
Expand All @@ -133,7 +133,7 @@ private function performSetup(Workspace $workspace): void
$this->gitAdapter->clone($projectInfo->gitUrl, $workspacePath, $projectInfo->githubToken);

// Step 3: Generate branch name and checkout
$branchName = $this->generateBranchName($workspaceId);
$branchName = $this->branchNameGenerator->generate($workspaceId, $userEmail);
$this->logger->debug('Creating branch', ['branchName' => $branchName]);
$this->gitAdapter->checkoutNewBranch($workspacePath, $branchName);

Expand Down Expand Up @@ -168,12 +168,4 @@ private function runProjectSetupSteps(ProjectInfoDto $projectInfo, string $works
'projectType' => $projectInfo->projectType->value,
]);
}

private function generateBranchName(string $workspaceId): string
{
$shortId = mb_substr($workspaceId, 0, 8);
$timestamp = DateAndTimeService::getDateTimeImmutable()->format('Ymd-His');

return 'ws-' . $shortId . '-' . $timestamp;
}
}
4 changes: 2 additions & 2 deletions src/WorkspaceMgmt/Facade/WorkspaceMgmtFacade.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public function getWorkspaceForProject(string $projectId): ?WorkspaceInfoDto
return $this->toDto($workspace);
}

public function dispatchSetupIfNeeded(string $projectId): WorkspaceInfoDto
public function dispatchSetupIfNeeded(string $projectId, string $userEmail): WorkspaceInfoDto
{
// Use transaction to prevent race conditions when creating workspace
$this->entityManager->beginTransaction();
Expand Down Expand Up @@ -94,7 +94,7 @@ public function dispatchSetupIfNeeded(string $projectId): WorkspaceInfoDto
$this->entityManager->flush();

// Dispatch async setup message
$this->messageBus->dispatch(new SetupWorkspaceMessage($workspaceId));
$this->messageBus->dispatch(new SetupWorkspaceMessage($workspaceId, $userEmail));
}

$this->entityManager->commit();
Expand Down
5 changes: 4 additions & 1 deletion src/WorkspaceMgmt/Facade/WorkspaceMgmtFacadeInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@ public function getWorkspaceForProject(string $projectId): ?WorkspaceInfoDto;
* Returns immediately with workspace info. Check workspace status
* to determine if setup is in progress.
*
* @param string $projectId the project ID
* @param string $userEmail email of the user triggering setup (used for human-friendly branch name)
*
* @return WorkspaceInfoDto workspace info (status may be IN_SETUP if setup was dispatched)
*/
public function dispatchSetupIfNeeded(string $projectId): WorkspaceInfoDto;
public function dispatchSetupIfNeeded(string $projectId, string $userEmail): WorkspaceInfoDto;

/**
* Transition workspace to IN_CONVERSATION status.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public function __invoke(SetupWorkspaceMessage $message): void
'workspaceId' => $message->workspaceId,
]);

$this->setupService->setup($workspace);
$this->setupService->setup($workspace, $message->userEmail);

$this->logger->info('Async workspace setup completed', [
'workspaceId' => $message->workspaceId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
readonly class SetupWorkspaceMessage implements ImmediateSymfonyMessageInterface
{
public function __construct(
public string $workspaceId
public string $workspaceId,
public string $userEmail,
) {
}
}
77 changes: 77 additions & 0 deletions tests/Unit/WorkspaceMgmt/BranchNameGeneratorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

namespace Tests\Unit\WorkspaceMgmt;

use App\WorkspaceMgmt\Domain\Service\BranchNameGenerator;

describe('BranchNameGenerator', function (): void {
describe('generate', function (): void {
it('returns branch name in format YYYY-MM-DD_HH-MM-SS-sanitizedEmail-shortWorkspaceId (URL/shell safe)', function (): void {
$generator = new BranchNameGenerator();
$workspaceId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
$userEmail = 'user@example.com';

$result = $generator->generate($workspaceId, $userEmail);

// Timestamp: YYYY-MM-DD_HH-MM-SS (no space, no colons)
expect($result)->toMatch('/^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-/');
// Sanitized email segment
expect($result)->toContain('-userATexampleDOTcom-');
// Short workspace ID (first 8 chars)
expect($result)->toEndWith('-aaaaaaaa');
});

it('uses first 8 characters of workspace ID as short ID', function (): void {
$generator = new BranchNameGenerator();
$workspaceId = 'deadbeef-1234-5678-abcd-ffffffffffff';
$userEmail = 'a@b.co';

$result = $generator->generate($workspaceId, $userEmail);

expect($result)->toEndWith('-deadbeef');
});

it('matches full format regex and contains sanitized email and short workspace id', function (): void {
$generator = new BranchNameGenerator();
$workspaceId = '12345678-aaaa-bbbb-cccc-dddddddddddd';
$userEmail = 'foo@bar.baz';

$result = $generator->generate($workspaceId, $userEmail);

// Full format: YYYY-MM-DD_HH-MM-SS-sanitizedEmail-shortId (shortId = 8 chars, URL/shell safe)
expect($result)->toMatch('/^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-fooATbarDOTbaz-12345678$/');
});
});

describe('sanitizeEmailForBranchName', function (): void {
it('replaces @ with AT and . with DOT', function (): void {
$generator = new BranchNameGenerator();

expect($generator->sanitizeEmailForBranchName('user@example.com'))
->toBe('userATexampleDOTcom');
});

it('normalizes email to lowercase', function (): void {
$generator = new BranchNameGenerator();

expect($generator->sanitizeEmailForBranchName('User@Example.COM'))
->toBe('userATexampleDOTcom');
});

it('handles multiple dots in domain', function (): void {
$generator = new BranchNameGenerator();

expect($generator->sanitizeEmailForBranchName('a@b.c.d'))
->toBe('aATbDOTcDOTd');
});

it('handles subdomain-style email', function (): void {
$generator = new BranchNameGenerator();

expect($generator->sanitizeEmailForBranchName('manuel@kiessling.net'))
->toBe('manuelATkiesslingDOTnet');
});
});
});