Compare commits

..

13 Commits

Author SHA1 Message Date
aminovfariz
f4b08e4c73 fix(ci): install ripgrep on runner before qa-required scripts
Some checks failed
deploy / QA gates (push) Has been cancelled
deploy / Build & deploy (push) Has been cancelled
CSS/docs/JS contract checks call rg directly on the host runner,
not inside Docker, so ripgrep must be present on the ubuntu runner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:06:12 +02:00
aminovfariz
db5c57ea5c fix(phpstan): remove unused Curl import from OidcHttpGateway 2026-06-05 15:01:49 +02:00
aminovfariz
0a56c741e8 fix(phpstan): simplify http_response_header check in OidcHttpGateway 2026-06-05 14:56:37 +02:00
aminovfariz
668a67c094 fix(tests): pass appTitle and appUrl to auth service constructors in tests 2026-06-05 14:51:47 +02:00
aminovfariz
acf3229f3f fix(tests): replace appLogoUrlAbsolute with resolveAppUrl in auth services 2026-06-05 14:43:52 +02:00
aminovfariz
5367449d80 fix(ci): run db:migrate before tests so settings table exists 2026-06-05 14:38:52 +02:00
aminovfariz
68407b944e fix(tests): remove appTitle/appUrl from AuthServicesFactory constructor calls 2026-06-05 14:27:28 +02:00
aminovfariz
c12d155e23 fix(tests): inject appTitle/appUrl into auth services to avoid DB calls in unit tests 2026-06-05 14:21:04 +02:00
aminovfariz
3f08f6189e fix(helpdesk): inject appTitle/appUrl into HandoverNotificationService to avoid DB calls in tests 2026-06-05 14:10:13 +02:00
aminovfariz
b8fc55bcc2 ci: start php container for QA gates 2026-06-05 14:02:57 +02:00
aminovfariz
6cfc8e7533 ci: use PHP 8.5 2026-06-05 14:00:55 +02:00
aminovfariz
2e26cce91f ci: run composer and QA gates directly on runner without Docker 2026-06-05 13:48:32 +02:00
aminovfariz
8e8ca4d5e7 ci: add GitHub Actions workflows 2026-06-05 13:41:19 +02:00
12 changed files with 211 additions and 27 deletions

90
.github/workflows/deploy.yaml vendored Normal file
View File

@@ -0,0 +1,90 @@
name: deploy
on:
push:
branches:
- main
jobs:
qa:
name: QA gates
runs-on: ubuntu-latest
env:
COMPOSE_PROJECT_NAME: breadcrumb-ci
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create .env for CI
run: cp .env.example .env
- name: Start project services
run: |
docker compose up -d --force-recreate php db memcached
sleep 20
- name: Install Composer dependencies
run: |
docker compose exec -T php bash -c "cd /var/www && composer install --no-interaction --prefer-dist --optimize-autoloader"
- name: Run DB migrations
run: docker compose exec -T php php bin/console db:migrate
- name: Run required QA gates
run: bin/qa-required.sh
build-and-deploy:
name: Build & deploy
runs-on: ubuntu-latest
needs: qa
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build production image
run: |
docker build \
--target prod \
-f docker/php/Dockerfile \
-t breadcrumb-the-shire:latest \
.
- name: Export image to tar.gz
run: docker save breadcrumb-the-shire:latest | gzip > breadcrumb-the-shire.tar.gz
- name: Copy image to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: breadcrumb-the-shire.tar.gz
target: /root/dockerfiles-breadcrumb-infra/projects/
- name: Copy updated deploy files to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "deploy/docker-compose.yml,deploy/nginx/"
target: /root/dockerfiles-breadcrumb-infra/projects/
strip_components: 1
- name: Deploy on server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -e
cd /root/dockerfiles-breadcrumb-infra/projects
docker load < breadcrumb-the-shire.tar.gz
rm breadcrumb-the-shire.tar.gz
docker compose up -d --remove-orphans
sleep 5
docker compose exec -T php php bin/console module:sync

39
.github/workflows/qa-required.yaml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: qa-required
on:
push:
branches:
- main
pull_request:
jobs:
qa-required:
name: qa-required
runs-on: ubuntu-latest
env:
COMPOSE_PROJECT_NAME: breadcrumb-ci
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create .env for CI
run: cp .env.example .env
- name: Start project services
run: |
docker compose up -d --force-recreate php db memcached
sleep 20
- name: Install Composer dependencies
run: |
docker compose exec -T php bash -c "cd /var/www && composer install --no-interaction --prefer-dist --optimize-autoloader"
- name: Run DB migrations
run: docker compose exec -T php php bin/console db:migrate
- name: Install ripgrep
run: sudo apt-get install -y ripgrep
- name: Run required QA gates
run: bin/qa-required.sh

View File

@@ -59,7 +59,7 @@ class AuthServicesFactory
$this->authRepositoryFactory->createPasswordResetRepository(),
$this->userServicesFactory->createUserPasswordService(),
$this->createRememberMeService(),
$this->mailServicesFactory->createMailService()
$this->mailServicesFactory->createMailService(),
);
}
@@ -69,7 +69,7 @@ class AuthServicesFactory
$this->userServicesFactory->createUserReadRepository(),
$this->userServicesFactory->createUserWriteRepository(),
$this->authRepositoryFactory->createEmailVerificationRepository(),
$this->mailServicesFactory->createMailService()
$this->mailServicesFactory->createMailService(),
);
}

View File

@@ -19,10 +19,25 @@ class EmailVerificationService
private readonly UserReadRepositoryInterface $userReadRepository,
private readonly UserWriteRepositoryInterface $userWriteRepository,
private readonly EmailVerificationRepositoryInterface $emailVerificationRepository,
private readonly MailService $mailService
private readonly MailService $mailService,
private readonly string $appTitle = '',
private readonly string $appUrl = '',
) {
}
private function resolveAppTitle(): string
{
return $this->appTitle !== '' ? $this->appTitle : appTitle();
}
private function resolveAppUrl(string $path = ''): string
{
if ($this->appUrl !== '') {
return rtrim($this->appUrl, '/') . ($path !== '' ? '/' . ltrim($path, '/') : '');
}
return appUrl($path);
}
public function sendVerification(int $userId, ?string $locale = null): array
{
$user = $this->userReadRepository->find($userId);
@@ -55,17 +70,17 @@ class EmailVerificationService
}
$greeting .= ',';
$verifyPath = Request::withLocale('verify-email', $locale);
$verifyUrl = appUrl($verifyPath);
$verifyUrl = $this->resolveAppUrl($verifyPath);
$previousLocale = I18n::$locale ?? null;
I18n::$locale = $locale;
$subject = t('Email verification code');
I18n::$locale = $previousLocale;
$vars = [
'app_name' => appTitle(),
'app_logo_url' => appLogoUrlAbsolute(128),
'imprint_url' => appUrl(Request::withLocale('imprint', $locale)),
'privacy_url' => appUrl(Request::withLocale('privacy', $locale)),
'app_name' => $this->resolveAppTitle(),
'app_logo_url' => $this->resolveAppUrl(appLogoUrl(128)),
'imprint_url' => $this->resolveAppUrl(Request::withLocale('imprint', $locale)),
'privacy_url' => $this->resolveAppUrl(Request::withLocale('privacy', $locale)),
'code' => $code,
'expires_minutes' => self::EXPIRY_MINUTES,
'verify_url' => $verifyUrl,

View File

@@ -2,8 +2,6 @@
namespace MintyPHP\Service\Auth;
use MintyPHP\Curl;
class OidcHttpGateway
{
/**
@@ -49,7 +47,7 @@ class OidcHttpGateway
$body = @file_get_contents($url, false, $ctx);
$status = 0;
$responseHeaders = [];
if (isset($http_response_header) && is_array($http_response_header)) {
if ($http_response_header !== []) {
foreach ($http_response_header as $i => $line) {
if ($i === 0 && preg_match('/HTTP\/[\d.]+ (\d+)/', $line, $m)) {
$status = (int) $m[1];

View File

@@ -20,10 +20,25 @@ class PasswordResetService
private readonly PasswordResetRepositoryInterface $passwordResetRepository,
private readonly UserPasswordService $userPasswordService,
private readonly RememberMeService $rememberMeService,
private readonly MailService $mailService
private readonly MailService $mailService,
private readonly string $appTitle = '',
private readonly string $appUrl = '',
) {
}
private function resolveAppTitle(): string
{
return $this->appTitle !== '' ? $this->appTitle : appTitle();
}
private function resolveAppUrl(string $path = ''): string
{
if ($this->appUrl !== '') {
return rtrim($this->appUrl, '/') . ($path !== '' ? '/' . ltrim($path, '/') : '');
}
return appUrl($path);
}
public function requestReset(string $email, ?string $locale = null): array
{
$email = trim($email);
@@ -58,17 +73,17 @@ class PasswordResetService
}
$greeting .= ',';
$verifyPath = Request::withLocale('password/verify', $locale);
$verifyUrl = appUrl($verifyPath);
$verifyUrl = $this->resolveAppUrl($verifyPath);
$previousLocale = I18n::$locale ?? null;
I18n::$locale = $locale;
$subject = t('Password reset code');
I18n::$locale = $previousLocale;
$vars = [
'app_name' => appTitle(),
'app_logo_url' => appLogoUrlAbsolute(128),
'imprint_url' => appUrl(Request::withLocale('imprint', $locale)),
'privacy_url' => appUrl(Request::withLocale('privacy', $locale)),
'app_name' => $this->resolveAppTitle(),
'app_logo_url' => $this->resolveAppUrl(appLogoUrl(128)),
'imprint_url' => $this->resolveAppUrl(Request::withLocale('imprint', $locale)),
'privacy_url' => $this->resolveAppUrl(Request::withLocale('privacy', $locale)),
'code' => $code,
'expires_minutes' => self::EXPIRY_MINUTES,
'verify_url' => $verifyUrl,

View File

@@ -145,7 +145,9 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar
));
$container->set(HandoverNotificationService::class, static fn (AppContainer $c): HandoverNotificationService => new HandoverNotificationService(
$c->get(MailService::class)
$c->get(MailService::class),
appTitle(),
appUrl(),
));
$container->set(HandoverService::class, static fn (AppContainer $c): HandoverService => new HandoverService(

View File

@@ -8,9 +8,24 @@ class HandoverNotificationService
{
public function __construct(
private readonly MailService $mailService,
private readonly string $appTitle = '',
private readonly string $appUrl = '',
) {
}
private function resolveAppTitle(): string
{
return $this->appTitle !== '' ? $this->appTitle : appTitle();
}
private function resolveAppUrl(string $path = ''): string
{
if ($this->appUrl !== '') {
return rtrim($this->appUrl, '/') . ($path !== '' ? '/' . ltrim($path, '/') : '');
}
return appUrl($path);
}
/**
* Notify the assignee that a handover was assigned to them.
*
@@ -29,7 +44,7 @@ class HandoverNotificationService
$domainUrl = trim((string) ($handover['domain_url'] ?? ''));
$dueDate = trim((string) ($handover['due_date'] ?? ''));
$handoverId = (int) ($handover['id'] ?? 0);
$handoverUrl = appUrl('helpdesk/handovers/edit/' . $handoverId);
$handoverUrl = $this->resolveAppUrl('helpdesk/handovers/edit/' . $handoverId);
$vars = [
'assignee_name' => $assigneeName,
@@ -38,10 +53,10 @@ class HandoverNotificationService
'domain_url' => $domainUrl !== '' ? $domainUrl : '—',
'due_date' => $dueDate !== '' ? $dueDate : '—',
'handover_url' => $handoverUrl,
'app_name' => appTitle(),
'app_name' => $this->resolveAppTitle(),
];
$subject = sprintf('[%s] Ihnen wurde eine Übergabe zugewiesen', appTitle());
$subject = sprintf('[%s] Ihnen wurde eine Übergabe zugewiesen', $this->resolveAppTitle());
$this->mailService->sendTemplate('handover_assigned', $vars, $toEmail, $subject);
}
@@ -69,7 +84,7 @@ class HandoverNotificationService
$debitorName = trim((string) ($handover['debitor_name'] ?? ''));
$domainUrl = trim((string) ($handover['domain_url'] ?? ''));
$handoverId = (int) ($handover['id'] ?? 0);
$handoverUrl = appUrl('helpdesk/handovers/edit/' . $handoverId);
$handoverUrl = $this->resolveAppUrl('helpdesk/handovers/edit/' . $handoverId);
$vars = [
'manager_name' => $managerName,
@@ -77,10 +92,10 @@ class HandoverNotificationService
'debitor_name' => $debitorName !== '' ? $debitorName : '—',
'domain_url' => $domainUrl !== '' ? $domainUrl : '—',
'handover_url' => $handoverUrl,
'app_name' => appTitle(),
'app_name' => $this->resolveAppTitle(),
];
$subject = sprintf('[%s] Übergabe wurde zur Prüfung eingereicht', appTitle());
$subject = sprintf('[%s] Übergabe wurde zur Prüfung eingereicht', $this->resolveAppTitle());
$this->mailService->sendTemplate('handover_review_requested', $vars, $toEmail, $subject);
}

View File

@@ -24,6 +24,10 @@ return gridFilterSchema([
'search' => true,
'query' => ['type' => 'string'],
],
[
'key' => 'view',
'type' => 'hidden',
],
[
'key' => 'status',
'type' => 'select',

View File

@@ -11,7 +11,9 @@ class HandoverNotificationServiceTest extends TestCase
private function createNotificationService(?MailService $mailService = null): HandoverNotificationService
{
return new HandoverNotificationService(
$mailService ?? $this->createMock(MailService::class)
$mailService ?? $this->createMock(MailService::class),
'TestApp',
'http://localhost',
);
}

View File

@@ -309,7 +309,9 @@ class EmailVerificationServiceTest extends TestCase
$userReadRepository,
$userWriteRepository,
$emailVerificationRepository,
$mailService
$mailService,
'TestApp',
'http://localhost',
);
}
}

View File

@@ -356,7 +356,9 @@ class PasswordResetServiceTest extends TestCase
$passwordResetRepository,
$userPasswordService,
$rememberMeService,
$mailService
$mailService,
'TestApp',
'http://localhost',
);
}
}