diff --git a/README.md b/README.md index 84ac4fd..5f5daed 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Each content type has a matching import command that reads its files and upserts | `php artisan pages:import` | `database/files/pages/*.yaml` | | `php artisan news:import` | `database/files/news/{locale}/*.md` | | `php artisan team:import` | `database/files/team/*.yaml` | +| `php artisan jobs:import` | `database/files/jobs/*.yaml` | | `php artisan services:import` | `database/files/services/{locale}/*.md` | | `php artisan products:import` | `database/files/products/{locale}/*.md` | | `php artisan technologies:import` | `database/files/technologies/{locale}/*.md` | diff --git a/app/Actions/ViewDataAction.php b/app/Actions/ViewDataAction.php index c30b2d6..2d88c51 100644 --- a/app/Actions/ViewDataAction.php +++ b/app/Actions/ViewDataAction.php @@ -10,6 +10,7 @@ use App\Enums\ContactSectionEnum; use App\Models\AiModel; use App\Models\Contact; +use App\Models\JobPosition; use App\Models\Network; use App\Models\News; use App\Models\OpenSource; @@ -166,6 +167,18 @@ public function contactsInSection(string $locale, ContactSectionEnum $section): return $contacts; } + /** + * @return Collection + */ + public function jobPositions(string $locale): Collection + { + $key = CacheKeyEnum::JOB_POSITIONS_PUBLISHED->forLocale($locale); + + return Cache::rememberForever($key, function () { + return JobPosition::where('published', true)->orderBy('sort')->get(); + }); + } + /** * @return Collection */ diff --git a/app/Console/Commands/ExportApplicationCommand.php b/app/Console/Commands/ExportApplicationCommand.php new file mode 100644 index 0000000..7a5c8f6 --- /dev/null +++ b/app/Console/Commands/ExportApplicationCommand.php @@ -0,0 +1,163 @@ +with('files')->find($this->argument('application')); + + if (! $application instanceof Application) { + $this->error('Application not found.'); + + return self::FAILURE; + } + + $slug = Str::slug($application->name()) ?: 'ohne-name'; + $filename = sprintf('bewerbung-%d-%s.zip', $application->id, $slug); + + $tempPath = tempnam(sys_get_temp_dir(), 'bewerbung'); + + if ($tempPath === false || ! $this->writeZip($application, $tempPath)) { + $this->error('Could not create the zip file.'); + + return self::FAILURE; + } + + if (is_string($directory = $this->option('dir'))) { + File::ensureDirectoryExists($directory); + File::move($tempPath, "{$directory}/{$filename}"); + + $this->info("Exported application #{$application->id} to {$directory}/{$filename}"); + + return self::SUCCESS; + } + + $path = Application::EXPORTS_DIRECTORY."/{$filename}"; + + $stream = fopen($tempPath, 'r'); + abort_if($stream === false, 500, 'Failed to read the zip file.'); + + Storage::disk('s3')->put($path, $stream); + fclose($stream); + unlink($tempPath); + + $url = Storage::disk('s3')->temporaryUrl($path, now()->addDays(7)); + + $this->info("Exported application #{$application->id} to s3:{$path}"); + $this->line('Download (valid for 7 days):'); + $this->line($url); + + return self::SUCCESS; + } + + private function writeZip(Application $application, string $path): bool + { + $zip = new ZipArchive; + + if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + return false; + } + + $zip->addFromString('bewerbung.md', $this->markdown($application)); + + $usedNames = []; + + foreach ($application->files as $file) { + $contents = Storage::disk($file->disk)->get($file->path); + + if ($contents === null) { + $this->warn("Skipped missing file: {$file->original_name} ({$file->path})"); + + continue; + } + + $zip->addFromString('attachments/'.$this->uniqueName($file, $usedNames), $contents); + } + + return $zip->close(); + } + + private function markdown(Application $application): string + { + $lines = [ + "# Bewerbung – {$application->name()}", + '', + '| Feld | Wert |', + '| --- | --- |', + "| ID | {$application->id} |", + '| Stelle | '.($application->job_key ?? '–').' |', + "| Status | {$application->status->value} |", + '| Eingereicht am | '.($application->submitted_at?->format('d.m.Y H:i') ?? '–').' |', + '| E-Mail | '.($application->email ?? '–').' |', + '| Alter | '.($application->age ?? '–').' |', + '| Ort | '.($application->city ?? '–').' |', + '| GitHub | '.($application->github ?? '–').' |', + '| LinkedIn | '.($application->linkedin ?? '–').' |', + '| Projekt-Link | '.($application->project_link ?? '–').' |', + ]; + + $sections = [ + 'Application question interests' => $application->interests, + 'Application question focus fit' => $application->focus_fit, + 'Application question built so far' => $application->built_so_far, + 'Application question about' => $application->about, + ]; + + foreach ($sections as $key => $value) { + $lines[] = ''; + $lines[] = '## '.__($key, [], 'de_CH'); + $lines[] = ''; + $lines[] = blank($value) ? '–' : trim($value); + } + + $lines[] = ''; + $lines[] = '## Anhänge'; + $lines[] = ''; + + if ($application->files->isEmpty()) { + $lines[] = '–'; + } + + foreach ($application->files as $file) { + $lines[] = "- {$file->original_name} ({$file->humanSize()})"; + } + + $lines[] = ''; + + return implode("\n", $lines); + } + + /** + * @param array $usedNames + */ + private function uniqueName(ApplicationFile $file, array &$usedNames): string + { + $name = basename($file->original_name); + + if (isset($usedNames[$name])) { + $name = $file->uuid.'-'.$name; + } + + $usedNames[$name] = true; + + return $name; + } +} diff --git a/app/Console/Commands/ImportJobPositionsCommand.php b/app/Console/Commands/ImportJobPositionsCommand.php new file mode 100644 index 0000000..5755cac --- /dev/null +++ b/app/Console/Commands/ImportJobPositionsCommand.php @@ -0,0 +1,140 @@ +yamlFiles(); + + if ($files === []) { + $this->components->warn('No job position files found under '.$this->basePath().'.'); + + return self::SUCCESS; + } + + $dryRun = $this->isDryRun(); + $imported = 0; + $skipped = 0; + $keys = []; + + foreach ($files as $path) { + $data = $this->parse($path); + + if ($data === null) { + $skipped++; + + continue; + } + + $key = $data['key']; + $keys[] = $key; + + $expected = $key.'.'.pathinfo($path, PATHINFO_EXTENSION); + + if (basename($path) !== $expected) { + $this->components->warn(sprintf('%s should be named %s.', basename($path), $expected)); + } + + if ($dryRun) { + $exists = JobPosition::where('key', $key)->exists(); + $this->components->twoColumnDetail($key, $exists ? 'would update' : 'would create'); + $imported++; + + continue; + } + + JobPosition::updateOrCreate(['key' => $key], [ + 'published' => $data['published'], + 'sort' => $data['sort'], + 'status' => $data['status'], + 'route_name' => $data['route_name'], + 'title' => $data['title'], + 'teaser' => $data['teaser'], + ]); + + $this->components->twoColumnDetail($key, 'imported'); + $imported++; + } + + if (! $dryRun) { + $this->removeOrphans(JobPosition::query(), 'key', $keys); + JobPosition::clearPublishedCache(); + } + + $this->newLine(); + $this->components->info(sprintf('%d position(s) %s, %d skipped.', $imported, $dryRun ? 'pending' : 'imported', $skipped)); + + return $skipped > 0 ? self::FAILURE : self::SUCCESS; + } + + protected function defaultPath(): string + { + return 'files/jobs'; + } + + /** + * @return array{key: string, published: bool, sort: int, status: JobPositionStatusEnum, route_name: string|null, title: array, teaser: array}|null + */ + private function parse(string $path): ?array + { + $parsed = $this->parseYamlFile($path); + + if ($parsed === null) { + return null; + } + + $key = $this->string($parsed['key'] ?? '') ?: pathinfo($path, PATHINFO_FILENAME); + $status = JobPositionStatusEnum::tryFrom($this->string($parsed['status'] ?? '')); + + if ($status === null) { + $statuses = implode(', ', array_column(JobPositionStatusEnum::cases(), 'value')); + $this->components->error(basename($path).' has no valid "status" — expected one of: '.$statuses.'.'); + + return null; + } + + $title = $this->localizedMap($parsed['title'] ?? null); + $missing = $this->missingLocales( + array_map(fn (LocaleEnum $case): string => $case->value, LocaleEnum::cases()), + $title + ); + + if ($missing !== []) { + $this->components->error(sprintf( + '%s is missing a "title" for %s — every position needs both languages.', + basename($path), + implode(', ', $missing) + )); + + return null; + } + + return [ + 'key' => $key, + 'published' => (bool) ($parsed['published'] ?? false), + 'sort' => is_int($parsed['sort'] ?? null) ? $parsed['sort'] : 0, + 'status' => $status, + 'route_name' => $this->nullableString($parsed['route_name'] ?? null), + 'title' => $title, + 'teaser' => $this->localizedMap($parsed['teaser'] ?? null), + ]; + } +} diff --git a/app/Console/Commands/PruneApplicationsCommand.php b/app/Console/Commands/PruneApplicationsCommand.php index b272342..6792152 100644 --- a/app/Console/Commands/PruneApplicationsCommand.php +++ b/app/Console/Commands/PruneApplicationsCommand.php @@ -27,6 +27,7 @@ public function handle(): int $file->deleteFromDisk(); } + $application->deleteExportsFromDisk(); $application->delete(); } diff --git a/app/Console/Commands/PurgeApplicationsCommand.php b/app/Console/Commands/PurgeApplicationsCommand.php new file mode 100644 index 0000000..14e76ed --- /dev/null +++ b/app/Console/Commands/PurgeApplicationsCommand.php @@ -0,0 +1,54 @@ +option('force') && ! $this->confirm('This permanently deletes ALL applications, uploaded documents, related notifications and export zips. Continue?')) { + $this->info('Aborted.'); + + return self::SUCCESS; + } + + $applications = Application::query()->with('files')->get(); + + foreach ($applications as $application) { + foreach ($application->files as $file) { + $file->deleteFromDisk(); + } + } + + $files = ApplicationFile::query()->count(); + $deleted = $applications->count(); + + ApplicationFile::query()->delete(); + Application::query()->delete(); + + $notifications = DB::table('notifications') + ->where('notifiable_type', Application::class) + ->delete(); + + Storage::disk('s3')->deleteDirectory('applications'); + + File::deleteDirectory(storage_path('app/exports')); + + $this->info("Purged {$deleted} applications, {$files} documents and {$notifications} notifications."); + + return self::SUCCESS; + } +} diff --git a/app/Enums/CacheKeyEnum.php b/app/Enums/CacheKeyEnum.php index 9145962..a8605d6 100644 --- a/app/Enums/CacheKeyEnum.php +++ b/app/Enums/CacheKeyEnum.php @@ -21,6 +21,8 @@ enum CacheKeyEnum: string { case CONTACTS_PUBLISHED = 'contacts_published'; + case JOB_POSITIONS_PUBLISHED = 'job_positions_published'; + case NEWS_PUBLISHED = 'news_published'; case PRODUCTS_PUBLISHED = 'products_published'; diff --git a/app/Enums/JobPositionStatusEnum.php b/app/Enums/JobPositionStatusEnum.php new file mode 100644 index 0000000..6cc3179 --- /dev/null +++ b/app/Enums/JobPositionStatusEnum.php @@ -0,0 +1,12 @@ +where('key', Application::JOB_KEY_INTERNSHIP)->first(); + + if ($position === null || ! $position->isOpen()) { + return redirect() + ->to(localized_route('jobs.internship.show')) + ->with('status', __('Internship closed teaser')); + } + $email = Str::lower($request->string('email')->value()); Application::query()->firstOrCreate([ - 'job_key' => Application::JOB_KEY_INTERNSHIP, + 'job_key' => $position->key, 'email' => $email, ]); SendApplicationLinkJob::dispatch( - Application::JOB_KEY_INTERNSHIP, + $position->key, $email, app()->getLocale(), ); diff --git a/app/Http/Controllers/Jobs/JobsIndexController.php b/app/Http/Controllers/Jobs/JobsIndexController.php index 0486a74..09f0959 100644 --- a/app/Http/Controllers/Jobs/JobsIndexController.php +++ b/app/Http/Controllers/Jobs/JobsIndexController.php @@ -5,15 +5,21 @@ namespace App\Http\Controllers\Jobs; use App\Actions\PageAction; +use App\Actions\ViewDataAction; use App\Http\Controllers\Controller; +use App\Models\JobPosition; use Illuminate\View\View; class JobsIndexController extends Controller { - public function __invoke(): View + public function __invoke(ViewDataAction $viewData): View { + $positions = $viewData->jobPositions(app()->getLocale()); + return view('app.jobs.index')->with([ 'page' => (new PageAction(locale: null, routeName: 'jobs.index'))->default(), + 'openPositions' => $positions->filter(fn (JobPosition $position): bool => $position->isOpen())->values(), + 'inProcessPositions' => $positions->filter(fn (JobPosition $position): bool => $position->isInProcess())->values(), ]); } } diff --git a/app/Http/Controllers/Jobs/JobsInternshipShowController.php b/app/Http/Controllers/Jobs/JobsInternshipShowController.php index adc25dd..c62cb2a 100644 --- a/app/Http/Controllers/Jobs/JobsInternshipShowController.php +++ b/app/Http/Controllers/Jobs/JobsInternshipShowController.php @@ -10,6 +10,8 @@ use App\DTO\PageDTO; use App\Enums\ContactSectionEnum; use App\Http\Controllers\Controller; +use App\Models\Application; +use App\Models\JobPosition; use App\Seo\SchemaNodes; use Illuminate\View\View; @@ -19,6 +21,8 @@ class JobsInternshipShowController extends Controller public function __invoke(ViewDataAction $viewData): View { + $position = JobPosition::query()->where('key', Application::JOB_KEY_INTERNSHIP)->first(); + $mentors = $viewData ->contactsInSection(app()->getLocale(), ContactSectionEnum::EMPLOYEES) ->filter(fn (ContactDTO $contact): bool => in_array($contact->key, self::MENTOR_KEYS, true)) @@ -26,10 +30,14 @@ public function __invoke(ViewDataAction $viewData): View $page = (new PageAction(locale: null, routeName: 'jobs.internship.show'))->default(); + $title = $position?->getTranslation('title', 'de_CH'); + $withSchema = $page instanceof PageDTO && $position !== null && $position->isOpen() && is_string($title); + return view('app.jobs.internship')->with([ 'page' => $page, + 'position' => $position, 'mentors' => $mentors, - 'schema' => $page instanceof PageDTO ? SchemaNodes::internshipJobPosting($page) : [], + 'schema' => $withSchema ? SchemaNodes::internshipJobPosting($page, $title) : [], ]); } } diff --git a/app/Models/Application.php b/app/Models/Application.php index 94791ae..5780161 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -8,8 +8,10 @@ use Database\Factories\ApplicationFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; class Application extends Model @@ -21,6 +23,8 @@ class Application extends Model public const string JOB_KEY_INTERNSHIP = 'praktikum-ims'; + public const string EXPORTS_DIRECTORY = 'applications/exports'; + /** @var list */ protected $fillable = [ 'job_key', @@ -54,6 +58,17 @@ public function files(): HasMany return $this->hasMany(ApplicationFile::class); } + /** + * The position lives as an imported content row that an import may delete and + * recreate, so the link goes over the stable key rather than a database id. + * + * @return BelongsTo + */ + public function jobPosition(): BelongsTo + { + return $this->belongsTo(JobPosition::class, 'job_key', 'key'); + } + public function isSubmitted(): bool { return $this->status === ApplicationStatusEnum::Submitted; @@ -67,6 +82,15 @@ public function markdownHtml(?string $markdown): ?string ]); } + public function deleteExportsFromDisk(): void + { + foreach (Storage::disk('s3')->files(self::EXPORTS_DIRECTORY) as $path) { + if (str_starts_with(basename($path), "bewerbung-{$this->id}-")) { + Storage::disk('s3')->delete($path); + } + } + } + public function name(): string { return trim(($this->first_name ?? '').' '.($this->last_name ?? '')); diff --git a/app/Models/JobPosition.php b/app/Models/JobPosition.php new file mode 100644 index 0000000..8c8dd82 --- /dev/null +++ b/app/Models/JobPosition.php @@ -0,0 +1,72 @@ + */ + use HasFactory; + + use HasTranslations; + + /** @var array */ + protected array $translatable = ['title', 'teaser']; + + /** @var list */ + protected $fillable = [ + 'key', + 'published', + 'sort', + 'status', + 'route_name', + 'title', + 'teaser', + ]; + + protected $casts = [ + 'published' => 'boolean', + 'status' => JobPositionStatusEnum::class, + ]; + + protected static function booted(): void + { + static::saved(fn () => self::clearPublishedCache()); + static::deleted(fn () => self::clearPublishedCache()); + } + + public static function clearPublishedCache(): void + { + foreach (CacheKeyEnum::JOB_POSITIONS_PUBLISHED->forAllLocales() as $key) { + Cache::forget($key); + } + } + + /** + * @return HasMany + */ + public function applications(): HasMany + { + return $this->hasMany(Application::class, 'job_key', 'key'); + } + + public function isOpen(): bool + { + return $this->status === JobPositionStatusEnum::Open; + } + + public function isInProcess(): bool + { + return $this->status === JobPositionStatusEnum::InProcess; + } +} diff --git a/app/Seo/SchemaNodes.php b/app/Seo/SchemaNodes.php index e683949..1259113 100644 --- a/app/Seo/SchemaNodes.php +++ b/app/Seo/SchemaNodes.php @@ -123,14 +123,14 @@ public static function team(Collection $contacts, PageDTO $page): array /** * @return array> */ - public static function internshipJobPosting(PageDTO $page): array + public static function internshipJobPosting(PageDTO $page, string $title): array { $location = collect(Company::locations())->first(); return [array_filter([ '@type' => 'JobPosting', '@id' => $page->url().'#jobposting', - 'title' => 'IMS-Praktikum 2027/28', + 'title' => $title, 'description' => $page->description, 'datePosted' => '2026-08-28', 'employmentType' => 'INTERN', diff --git a/composer.lock b/composer.lock index 8979d9c..7868eb0 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.394.2", + "version": "3.394.3", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "3123c895f535792857e8c2ce345ee025606d648d" + "reference": "d015718cd1becf0be9f75a1e72a7401453de4f3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/3123c895f535792857e8c2ce345ee025606d648d", - "reference": "3123c895f535792857e8c2ce345ee025606d648d", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d015718cd1becf0be9f75a1e72a7401453de4f3c", + "reference": "d015718cd1becf0be9f75a1e72a7401453de4f3c", "shasum": "" }, "require": { @@ -153,9 +153,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.394.2" + "source": "https://github.com/aws/aws-sdk-php/tree/3.394.3" }, - "time": "2026-08-27T18:54:08+00:00" + "time": "2026-08-28T18:18:55+00:00" }, { "name": "bacon/bacon-qr-code", @@ -5387,16 +5387,16 @@ }, { "name": "symfony/console", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "d07c06839e33047e2c894a6793248f3fb66c8129" + "reference": "eb7d9957d66739649e931ce7a9d05dab69f8abac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/d07c06839e33047e2c894a6793248f3fb66c8129", - "reference": "d07c06839e33047e2c894a6793248f3fb66c8129", + "url": "https://api.github.com/repos/symfony/console/zipball/eb7d9957d66739649e931ce7a9d05dab69f8abac", + "reference": "eb7d9957d66739649e931ce7a9d05dab69f8abac", "shasum": "" }, "require": { @@ -5463,7 +5463,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.1.5" + "source": "https://github.com/symfony/console/tree/v8.1.6" }, "funding": [ { @@ -5483,20 +5483,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T14:29:57+00:00" + "time": "2026-08-25T14:18:42+00:00" }, { "name": "symfony/css-selector", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "a291fb5adb65f52a4bb315db2d803698315dc64d" + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/a291fb5adb65f52a4bb315db2d803698315dc64d", - "reference": "a291fb5adb65f52a4bb315db2d803698315dc64d", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/08e2905152a39cf3fd1745d83f8c483e258887d9", + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9", "shasum": "" }, "require": { @@ -5532,7 +5532,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.1.5" + "source": "https://github.com/symfony/css-selector/tree/v8.1.6" }, "funding": [ { @@ -5552,7 +5552,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-08-23T10:06:25+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5944,16 +5944,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "6b2f4a0eeb28b5d74f90862592923a654bc629b3" + "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/6b2f4a0eeb28b5d74f90862592923a654bc629b3", - "reference": "6b2f4a0eeb28b5d74f90862592923a654bc629b3", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7599ebb855fede59413ddb7f15198d67bfcae7ba", + "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba", "shasum": "" }, "require": { @@ -5991,7 +5991,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.1.5" + "source": "https://github.com/symfony/filesystem/tree/v8.1.6" }, "funding": [ { @@ -6011,7 +6011,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:16:08+00:00" + "time": "2026-08-23T10:06:25+00:00" }, { "name": "symfony/finder", @@ -6083,16 +6083,16 @@ }, { "name": "symfony/http-client", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "9f941ed000bb11f16dc7eafed98a7b646d3b3e5d" + "reference": "fe91dd1ddf09b61aa01e73d29907acbf1775c0a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/9f941ed000bb11f16dc7eafed98a7b646d3b3e5d", - "reference": "9f941ed000bb11f16dc7eafed98a7b646d3b3e5d", + "url": "https://api.github.com/repos/symfony/http-client/zipball/fe91dd1ddf09b61aa01e73d29907acbf1775c0a9", + "reference": "fe91dd1ddf09b61aa01e73d29907acbf1775c0a9", "shasum": "" }, "require": { @@ -6156,7 +6156,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v8.1.5" + "source": "https://github.com/symfony/http-client/tree/v8.1.6" }, "funding": [ { @@ -6176,20 +6176,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-08-30T14:03:40+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f", "shasum": "" }, "require": { @@ -6238,7 +6238,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.3" }, "funding": [ { @@ -6258,20 +6258,20 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-08-04T08:41:16+00:00" }, { "name": "symfony/http-foundation", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "ee16f97e95cfa011a742714d7c8c8f70fe7423f4" + "reference": "093b78326f649c3a9db922b9f17123b6aeb3b8fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ee16f97e95cfa011a742714d7c8c8f70fe7423f4", - "reference": "ee16f97e95cfa011a742714d7c8c8f70fe7423f4", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/093b78326f649c3a9db922b9f17123b6aeb3b8fb", + "reference": "093b78326f649c3a9db922b9f17123b6aeb3b8fb", "shasum": "" }, "require": { @@ -6319,7 +6319,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.1.5" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.6" }, "funding": [ { @@ -6339,20 +6339,20 @@ "type": "tidelift" } ], - "time": "2026-08-20T09:59:12+00:00" + "time": "2026-08-30T20:10:55+00:00" }, { "name": "symfony/http-kernel", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "0306e1e65b90023fe40de6c6be95d06396bcb2e6" + "reference": "2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/0306e1e65b90023fe40de6c6be95d06396bcb2e6", - "reference": "0306e1e65b90023fe40de6c6be95d06396bcb2e6", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355", + "reference": "2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355", "shasum": "" }, "require": { @@ -6429,7 +6429,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.1.5" + "source": "https://github.com/symfony/http-kernel/tree/v8.1.6" }, "funding": [ { @@ -6449,7 +6449,7 @@ "type": "tidelift" } ], - "time": "2026-08-22T13:45:00+00:00" + "time": "2026-08-30T21:40:49+00:00" }, { "name": "symfony/mailer", @@ -6533,7 +6533,7 @@ }, { "name": "symfony/mime", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", @@ -6595,7 +6595,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.1.5" + "source": "https://github.com/symfony/mime/tree/v8.1.6" }, "funding": [ { @@ -7448,16 +7448,16 @@ }, { "name": "symfony/postmark-mailer", - "version": "v8.1.0", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/postmark-mailer.git", - "reference": "ba956b746121b4922455c79e38f8ab16a8f7bf88" + "reference": "96b8ee2235e3c01fc659e6d5f33f18e3f917bea6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/postmark-mailer/zipball/ba956b746121b4922455c79e38f8ab16a8f7bf88", - "reference": "ba956b746121b4922455c79e38f8ab16a8f7bf88", + "url": "https://api.github.com/repos/symfony/postmark-mailer/zipball/96b8ee2235e3c01fc659e6d5f33f18e3f917bea6", + "reference": "96b8ee2235e3c01fc659e6d5f33f18e3f917bea6", "shasum": "" }, "require": { @@ -7495,7 +7495,7 @@ "description": "Symfony Postmark Mailer Bridge", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/postmark-mailer/tree/v8.1.0" + "source": "https://github.com/symfony/postmark-mailer/tree/v8.1.6" }, "funding": [ { @@ -7515,11 +7515,11 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-30T01:03:44+00:00" }, { "name": "symfony/process", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/process.git", @@ -7560,7 +7560,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.1.5" + "source": "https://github.com/symfony/process/tree/v8.1.6" }, "funding": [ { @@ -7584,7 +7584,7 @@ }, { "name": "symfony/routing", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", @@ -7640,7 +7640,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.1.5" + "source": "https://github.com/symfony/routing/tree/v8.1.6" }, "funding": [ { @@ -7664,16 +7664,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -7727,7 +7727,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -7747,7 +7747,7 @@ "type": "tidelift" } ], - "time": "2026-06-16T09:55:08+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/string", @@ -8094,16 +8094,16 @@ }, { "name": "symfony/var-dumper", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "61743d9bc7ab23b194527ca1be2fafd7dc93b74a" + "reference": "3783365b58972f4779254d98372af80fbf15e170" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/61743d9bc7ab23b194527ca1be2fafd7dc93b74a", - "reference": "61743d9bc7ab23b194527ca1be2fafd7dc93b74a", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/3783365b58972f4779254d98372af80fbf15e170", + "reference": "3783365b58972f4779254d98372af80fbf15e170", "shasum": "" }, "require": { @@ -8157,7 +8157,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.1.5" + "source": "https://github.com/symfony/var-dumper/tree/v8.1.6" }, "funding": [ { @@ -8177,20 +8177,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:16:08+00:00" + "time": "2026-08-30T20:10:55+00:00" }, { "name": "symfony/yaml", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "b3fc9e8888eeb9daddc33bfbd15d282a61f543cd" + "reference": "0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/b3fc9e8888eeb9daddc33bfbd15d282a61f543cd", - "reference": "b3fc9e8888eeb9daddc33bfbd15d282a61f543cd", + "url": "https://api.github.com/repos/symfony/yaml/zipball/0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5", + "reference": "0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5", "shasum": "" }, "require": { @@ -8233,7 +8233,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.1.5" + "source": "https://github.com/symfony/yaml/tree/v8.1.6" }, "funding": [ { @@ -8253,7 +8253,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:16:08+00:00" + "time": "2026-08-30T01:03:44+00:00" }, { "name": "tempest/highlight", @@ -10773,16 +10773,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.3", + "version": "2.3.4", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + "reference": "98dbc9412932af5825e6d5aa5d6bc4de7d82538a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", - "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/98dbc9412932af5825e6d5aa5d6bc4de7d82538a", + "reference": "98dbc9412932af5825e6d5aa5d6bc4de7d82538a", "shasum": "" }, "require": { @@ -10814,17 +10814,17 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.4" }, - "time": "2026-07-08T07:01:06+00:00" + "time": "2026-08-30T16:25:38+00:00" }, { "name": "phpstan/phpstan", - "version": "2.2.9", + "version": "2.2.10", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/13d6b4f347bad222da436580c8304fa6f83e6bd0", - "reference": "13d6b4f347bad222da436580c8304fa6f83e6bd0", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/36d1509c998b0602811824143526b4a9ee2774e7", + "reference": "36d1509c998b0602811824143526b4a9ee2774e7", "shasum": "" }, "require": { @@ -10880,7 +10880,7 @@ "type": "github" } ], - "time": "2026-08-22T07:38:16+00:00" + "time": "2026-08-30T12:46:16+00:00" }, { "name": "phpunit/php-code-coverage", @@ -12840,16 +12840,16 @@ }, { "name": "tomasvotruba/type-coverage", - "version": "2.3.4", + "version": "2.3.6", "source": { "type": "git", "url": "https://github.com/TomasVotruba/type-coverage.git", - "reference": "7b4aec57af15514dac9a3c5a9671da501444ecd0" + "reference": "bb2e1d115bfc38b2b29c98ea64762d70607a684f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/TomasVotruba/type-coverage/zipball/7b4aec57af15514dac9a3c5a9671da501444ecd0", - "reference": "7b4aec57af15514dac9a3c5a9671da501444ecd0", + "url": "https://api.github.com/repos/TomasVotruba/type-coverage/zipball/bb2e1d115bfc38b2b29c98ea64762d70607a684f", + "reference": "bb2e1d115bfc38b2b29c98ea64762d70607a684f", "shasum": "" }, "require": { @@ -12895,7 +12895,7 @@ ], "support": { "issues": "https://github.com/TomasVotruba/type-coverage/issues", - "source": "https://github.com/TomasVotruba/type-coverage/tree/2.3.4" + "source": "https://github.com/TomasVotruba/type-coverage/tree/2.3.6" }, "funding": [ { @@ -12907,7 +12907,7 @@ "type": "github" } ], - "time": "2026-08-18T07:48:32+00:00" + "time": "2026-08-29T08:21:40+00:00" }, { "name": "webmozart/assert", diff --git a/database/factories/JobPositionFactory.php b/database/factories/JobPositionFactory.php new file mode 100644 index 0000000..c2a8e94 --- /dev/null +++ b/database/factories/JobPositionFactory.php @@ -0,0 +1,35 @@ + + */ +class JobPositionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $title = fake()->unique()->jobTitle(); + + return [ + 'key' => str($title)->slug()->toString(), + 'published' => true, + 'sort' => fake()->numberBetween(1, 99), + 'status' => JobPositionStatusEnum::Open, + 'route_name' => null, + 'title' => ['de_CH' => $title, 'en_CH' => $title], + 'teaser' => ['de_CH' => fake()->sentence(), 'en_CH' => fake()->sentence()], + ]; + } +} diff --git a/database/files/jobs/praktikum-ims.yaml b/database/files/jobs/praktikum-ims.yaml new file mode 100644 index 0000000..b94388a --- /dev/null +++ b/database/files/jobs/praktikum-ims.yaml @@ -0,0 +1,11 @@ +key: praktikum-ims +published: true +sort: 1 +status: in-process +route_name: jobs.internship.show +title: + de_CH: 'IMS-Praktikum 2027/28' + en_CH: 'IMS Internship 2027/28' +teaser: + de_CH: 'Der ganze Weg der Softwareentwicklung: planen, entwickeln, betreiben – ein bis anderthalb Jahre mitten im Projektalltag, mit PHP, Laravel und Open Source.' + en_CH: 'The whole journey of software development: plan, build, run – one to one and a half years inside real projects, with PHP, Laravel and open source.' diff --git a/database/migrations/2026_08_31_100000_create_job_positions_table.php b/database/migrations/2026_08_31_100000_create_job_positions_table.php new file mode 100644 index 0000000..72b5c5f --- /dev/null +++ b/database/migrations/2026_08_31_100000_create_job_positions_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('key')->unique(); + $table->boolean('published')->default(false); + $table->unsignedSmallInteger('sort')->default(0); + $table->string('status'); + $table->string('route_name')->nullable(); + $table->json('title'); + $table->json('teaser')->nullable(); + $table->timestamps(); + + $table->index(['published', 'sort']); + }); + } + + public function down(): void + { + Schema::dropIfExists('job_positions'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 4dab0c8..7084710 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -38,6 +38,7 @@ public function run(): void $this->call(PagesTableSeeder::class); $this->call(SeoImageCleanupSeeder::class); $this->call(ContactsTableSeeder::class); + $this->call(JobPositionsTableSeeder::class); $this->call(NewsTableSeeder::class); // OpenSource content comes from `php artisan sync:repositories`, which // pulls the live GitHub repositories — the seed data here is stale. diff --git a/database/seeders/JobPositionsTableSeeder.php b/database/seeders/JobPositionsTableSeeder.php new file mode 100644 index 0000000..9903c2c --- /dev/null +++ b/database/seeders/JobPositionsTableSeeder.php @@ -0,0 +1,21 @@ +command->getOutput()); + } +} diff --git a/lang/de_CH.json b/lang/de_CH.json index 1e7f4f4..4943df9 100644 --- a/lang/de_CH.json +++ b/lang/de_CH.json @@ -141,6 +141,8 @@ "Internship bring intro": "Wir starten bei deinem bestehenden Wissensstand. Mitbringen solltest du:", "Internship bring learning": "Selbstständiges Lernen & Arbeiten", "Internship bring passion": "Lust, Software zu konzipieren & entwickeln", + "Internship closed body": "Für das IMS-Praktikum 2027/28 haben wir genügend Bewerbungen erhalten – das Bewerbungsfenster ist geschlossen. Wer bereits eine Bewerbung gestartet hat, kann sie über den persönlichen Link weiterhin abschliessen.", + "Internship closed teaser": "Das Bewerbungsfenster ist geschlossen – wir haben genügend Bewerbungen erhalten. Danke für das grosse Interesse!", "Internship focus body": "Die IT ist ein riesiges Feld – aufgrund unserer Grösse können wir nur einen kleinen Teil davon abdecken: Web-Entwicklung mit Laravel und Open Source. Wenn dich vor allem Game-Entwicklung oder Netzwerk-Infrastruktur interessiert, sind wir vermutlich nicht der richtige Ort.", "Internship focus heading": "Ehrlich zum Fokus", "Internship journey heading": "Der ganze Weg. Idee bis Betrieb.", @@ -163,18 +165,17 @@ "Internship team body": "Tobias und Julian betreuen das Praktikum bei uns – melde dich direkt bei ihnen, per E-Mail oder LinkedIn.", "Internship team heading": "Fragen zum Praktikum?", "Internship title": "IMS-Praktikum 2027/28", + "Job in process note": "Der Bewerbungsprozess läuft – wir nehmen keine weiteren Bewerbungen entgegen. Danke für das grosse Interesse!", + "Job status in process": "Bewerbungsprozess gestartet", "Italic": "Kursiv", "JPG, PNG, WebP or AVIF, 1:1, max. 2 MB.": "JPG, PNG, WebP oder AVIF, 1:1, max. 2 MB.", "JPG, PNG, WebP or AVIF, 3:1, max. 4 MB.": "JPG, PNG, WebP oder AVIF, 3:1, max. 4 MB.", "Jobs": "Stellen", - "Jobs internship teaser": "Der ganze Weg der Softwareentwicklung: planen, entwickeln, betreiben – ein bis anderthalb Jahre mitten im Projektalltag, mit PHP, Laravel und Open Source.", "Jobs intro": "Wir sind ein kleines Team aus der Region Basel – klein aus Überzeugung. Bei uns gibt es keine Abteilungen, an die man Arbeit abgibt: Jede:r deckt mehrere Rollen ab, vom Kundengespräch bis zum Code. Wir entwickeln mit offenen Technologien wie Laravel, geben eigene Packages an die Community zurück und setzen alles, was wir anbieten, auch selbst ein. Wer bei uns anfängt, übernimmt vom ersten Tag Verantwortung in echten Projekten – und lernt ständig dazu.", "Jobs intro heading": "Was dich bei uns erwartet", "Jobs no open positions": "Zurzeit sind keine Stellen ausgeschrieben.", "Jobs open positions heading": "Offene Stellen", "Jobs page header": "Lust, mit uns Software zu bauen? Hier erfährst du, wie wir arbeiten, was wir suchen – und wie du an Bord kommst.", - "Jobs spontaneous body": "Du findest, wir sollten dich kennenlernen? Wir freuen uns auch ohne ausgeschriebene Stelle über deine Bewerbung – erzähl uns, was du kannst und was dich antreibt:", - "Jobs spontaneous heading": "Initiativbewerbung", "Jobs training body": "Ausbildung gehört bei uns dazu. Seit zwei Jahren bieten wir Praktikumsplätze an – jeweils für ein bis anderthalb Jahre, mitten im Projektalltag statt am Rand. Du startest auf deinem Wissensstand, bekommst echte Aufgaben und eine Ansprechperson, die selbst programmiert. Offene Praktikumsstellen schreiben wir jeweils aus.", "Jobs training heading": "Ausbildung & Praktika", "Keyless entry via RFID & app — no physical keys.": "Keyless Entry per RFID & App — kein physischer Schlüssel.", diff --git a/lang/en_CH.json b/lang/en_CH.json index 49bf300..108a8f6 100644 --- a/lang/en_CH.json +++ b/lang/en_CH.json @@ -137,6 +137,8 @@ "Internship bring intro": "We start from what you already know. You should bring:", "Internship bring learning": "Independent learning & working", "Internship bring passion": "A drive to design & build software", + "Internship closed body": "We have received enough applications for the IMS internship 2027/28 – the application window is closed. If you have already started an application, you can still complete it via your personal link.", + "Internship closed teaser": "The application window is closed – we have received enough applications. Thank you for the great interest!", "Internship focus body": "IT is a huge field – given our size we can only cover a small part of it: web development with Laravel and open source. If game development or network infrastructure is what excites you most, we are probably not the right place.", "Internship focus heading": "Honest about our focus", "Internship journey heading": "The whole journey. Idea to operations.", @@ -159,18 +161,17 @@ "Internship team body": "Tobias and Julian mentor the internship with us – reach out to them directly, by email or on LinkedIn.", "Internship team heading": "Questions about the internship?", "Internship title": "IMS Internship 2027/28", + "Job in process note": "The application process is underway – we are no longer accepting applications. Thank you for the great interest!", + "Job status in process": "Application process underway", "Italic": "Italic", "JPG, PNG, WebP or AVIF, 1:1, max. 2 MB.": "JPG, PNG, WebP or AVIF, 1:1, max. 2 MB.", "JPG, PNG, WebP or AVIF, 3:1, max. 4 MB.": "JPG, PNG, WebP or AVIF, 3:1, max. 4 MB.", "Jobs": "Jobs", - "Jobs internship teaser": "The whole journey of software development: plan, build, run – one to one and a half years inside real projects, with PHP, Laravel and open source.", "Jobs intro": "We're a small team based in the Basel region – small by conviction. There are no departments to hand work off to: everyone covers several roles, from client conversations to code. We build with open technologies such as Laravel, give our own packages back to the community, and use everything we offer ourselves. Whoever joins us takes on responsibility in real projects from day one – and keeps learning.", "Jobs intro heading": "What to expect here", "Jobs no open positions": "There are currently no open positions.", "Jobs open positions heading": "Open positions", "Jobs page header": "Fancy building software with us? Here's how we work, what we're looking for – and how you can come on board.", - "Jobs spontaneous body": "Think we should meet? We welcome your application even without an advertised position – tell us what you can do and what drives you:", - "Jobs spontaneous heading": "Speculative application", "Jobs training body": "Training is part of who we are. For two years now, we've been offering internships – one to one and a half years each, right inside real projects rather than on the sidelines. You start at your level, get real tasks and a contact person who codes themselves. We advertise open internship positions as they come up.", "Jobs training heading": "Training & internships", "Keyless entry via RFID & app — no physical keys.": "Keyless entry via RFID & app — no physical keys.", diff --git a/package-lock.json b/package-lock.json index 67be68b..51ef54f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,18 +37,18 @@ } }, "node_modules/@alpinejs/csp": { - "version": "3.16.3", - "resolved": "https://registry.npmjs.org/@alpinejs/csp/-/csp-3.16.3.tgz", - "integrity": "sha512-eqz6rpWDXuJOGp3YvsXQqZljjBz7ZWAkEmJUt3zTJyK9SEjUcInfOx7sRLzEUlrU+o9r2UhxCVhFBc5eJAUMdA==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@alpinejs/csp/-/csp-3.17.0.tgz", + "integrity": "sha512-D6W+RbUfkU1SyAYM0GHNF84jEHtx77HQq5CdOBvlBwDzKqbdTqjITPRLNihZ/EcUqoLgqebFzHd/v55rpP0Jhw==", "license": "MIT", "dependencies": { "@vue/reactivity": "~3.5.40" } }, "node_modules/@alpinejs/focus": { - "version": "3.16.3", - "resolved": "https://registry.npmjs.org/@alpinejs/focus/-/focus-3.16.3.tgz", - "integrity": "sha512-MwEeux0W+/DP4B3FyNHPs+kzqMmgfWRf25WsJLBH1kahBqiVHNLewSJotbO+9345A8WsHz3yx8XkaeNnepDv3Q==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@alpinejs/focus/-/focus-3.17.0.tgz", + "integrity": "sha512-tqkE2Pj4WgSXXujs3eCLxiS2ZODgNcDsKvG5lCZKlDozeRO6WFeSAjV5EKi4p+BFNIwm1937O4xwuV7hQ0Q+jA==", "license": "MIT", "dependencies": { "focus-trap": "^8.0.0", @@ -164,9 +164,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.1.tgz", - "integrity": "sha512-YpAJZhaHplYQkG8ib+/Fx5Y0eF2lVWi3tIvMJA6i39TLyUNp2439cifzW8VMjhlqrBjHzK5hVGugRRm2zTKI/A==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", + "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", "dev": true, "funding": [ { @@ -215,9 +215,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.9.tgz", - "integrity": "sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.10.tgz", + "integrity": "sha512-xBja6gaAaH2R2c7eNyl0TY4dhnnZ2uhj+KXpLdEQ6M/wuk9bYFZM8wY0ykw3VO4TgEJ56KGlerXS/9KBKVR/Cg==", "dev": true, "funding": [ { @@ -2022,9 +2022,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.416", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.416.tgz", - "integrity": "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==", + "version": "1.5.417", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.417.tgz", + "integrity": "sha512-4T+DTDWuMPM4aHlHwWdAVCVWwp7LDilnhzkj+c/Lbj91XSQrLuOmZSLtS9Q4iIqjlPUbPOnC624zDVVHCHaolQ==", "license": "ISC" }, "node_modules/emoji-regex": { diff --git a/resources/views/app/jobs/index.blade.php b/resources/views/app/jobs/index.blade.php index 9f92b1b..33c12b7 100644 --- a/resources/views/app/jobs/index.blade.php +++ b/resources/views/app/jobs/index.blade.php @@ -13,24 +13,33 @@

{{ __('Jobs training body') }}

- - - - - -

{{ __('Jobs internship teaser') }}

- -
+ @foreach($inProcessPositions as $position) + + + + + {{ __('Job status in process') }} + +

{{ __('Job in process note') }}

+
+ @endforeach
- - -

- {{ __('Jobs spontaneous body') }} - -

-
+ + @forelse($openPositions as $position) + ! $loop->first])> + + @if(filled($position->teaser)) +

{{ $position->teaser }}

+ @endif + @if($position->route_name) + + @endif +
+ @empty +

{{ __('Jobs no open positions') }}

+ @endforelse
diff --git a/resources/views/app/jobs/internship.blade.php b/resources/views/app/jobs/internship.blade.php index 821b1fa..8d25679 100644 --- a/resources/views/app/jobs/internship.blade.php +++ b/resources/views/app/jobs/internship.blade.php @@ -66,7 +66,13 @@ - @include('app.jobs.partials.apply', ['fieldId' => 'email']) + @if($position?->isOpen()) + @include('app.jobs.partials.apply', ['fieldId' => 'email']) + @else + +

{{ __('Internship closed body') }}

+
+ @endif
@if($mentors->isNotEmpty()) diff --git a/resources/views/components/ui/badge.blade.php b/resources/views/components/ui/badge.blade.php index 4692c42..665aff1 100644 --- a/resources/views/components/ui/badge.blade.php +++ b/resources/views/components/ui/badge.blade.php @@ -14,6 +14,7 @@ 'brand' => 'bg-brand text-white', 'success' => 'bg-emerald-500/10 text-emerald-700 ring-1 ring-emerald-600/20 ring-inset', 'metal' => 'bg-linear-to-b from-gray-100 via-white to-gray-300 text-gray-700 ring-1 ring-gray-400/40 ring-inset', + 'notice' => 'bg-brand/10 text-brand ring-1 ring-brand/25 ring-inset', ]; $hovers = [ @@ -22,6 +23,7 @@ 'brand' => 'hover:bg-brand-strong', 'success' => '', 'metal' => '', + 'notice' => '', ]; $sizes = [ diff --git a/tests/Feature/Commands/ExportApplicationCommandTest.php b/tests/Feature/Commands/ExportApplicationCommandTest.php new file mode 100644 index 0000000..769a939 --- /dev/null +++ b/tests/Feature/Commands/ExportApplicationCommandTest.php @@ -0,0 +1,76 @@ +buildTemporaryUrlsUsing( + fn (string $path): string => "https://s3.example/signed/{$path}", + ); + Storage::disk('s3')->put('applications/documents/cv.pdf', 'pdf-content'); + + $application = Application::factory()->submitted()->create([ + 'first_name' => 'Mira', + 'last_name' => 'Muster', + 'about' => 'Ich baue gerne Dinge.', + ]); + + ApplicationFile::factory()->create([ + 'application_id' => $application->id, + 'path' => 'applications/documents/cv.pdf', + 'original_name' => 'Lebenslauf.pdf', + ]); + + $zipPath = "applications/exports/bewerbung-{$application->id}-mira-muster.zip"; + + runArtisan('applications:export', ['application' => $application->id]) + ->expectsOutputToContain("https://s3.example/signed/{$zipPath}") + ->assertSuccessful(); + + Storage::disk('s3')->assertExists($zipPath); + + $zip = new ZipArchive; + $zip->open(Storage::disk('s3')->path($zipPath)); + + expect($zip->getFromName('bewerbung.md')) + ->toContain('Mira Muster') + ->toContain('Ich baue gerne Dinge.') + ->and($zip->getFromName('attachments/Lebenslauf.pdf'))->toBe('pdf-content'); + + $zip->close(); +})->group('applications'); + +it('exports an application to a local directory when requested', function () { + Storage::fake('s3'); + + $application = Application::factory()->submitted()->create([ + 'first_name' => 'Mira', + 'last_name' => 'Muster', + ]); + + $dir = sys_get_temp_dir().'/application-export-'.uniqid(); + + runArtisan('applications:export', ['application' => $application->id, '--dir' => $dir]) + ->assertSuccessful(); + + $zipPath = "{$dir}/bewerbung-{$application->id}-mira-muster.zip"; + + expect(file_exists($zipPath))->toBeTrue(); + + $zip = new ZipArchive; + $zip->open($zipPath); + + expect($zip->getFromName('bewerbung.md'))->toContain('Mira Muster'); + + $zip->close(); + File::deleteDirectory($dir); +})->group('applications'); + +it('fails for an unknown application', function () { + runArtisan('applications:export', ['application' => 999])->assertFailed(); +})->group('applications'); diff --git a/tests/Feature/Commands/PruneApplicationsCommandTest.php b/tests/Feature/Commands/PruneApplicationsCommandTest.php index efcfb3c..125f432 100644 --- a/tests/Feature/Commands/PruneApplicationsCommandTest.php +++ b/tests/Feature/Commands/PruneApplicationsCommandTest.php @@ -18,12 +18,17 @@ $stale->timestamps = false; $stale->forceFill(['updated_at' => now()->subMonths(7)])->save(); + Storage::disk('s3')->put("applications/exports/bewerbung-{$stale->id}-mira-muster.zip", 'zip'); + Storage::disk('s3')->put('applications/exports/bewerbung-999-other.zip', 'zip'); + runArtisan('applications:prune')->assertSuccessful(); expect(Application::query()->count())->toBe(0) ->and(ApplicationFile::query()->count())->toBe(0); Storage::disk('s3')->assertMissing('applications/documents/stale.pdf'); + Storage::disk('s3')->assertMissing("applications/exports/bewerbung-{$stale->id}-mira-muster.zip"); + Storage::disk('s3')->assertExists('applications/exports/bewerbung-999-other.zip'); })->group('applications'); it('keeps fresh drafts and submitted applications', function () { diff --git a/tests/Feature/Commands/PurgeApplicationsCommandTest.php b/tests/Feature/Commands/PurgeApplicationsCommandTest.php new file mode 100644 index 0000000..3521c31 --- /dev/null +++ b/tests/Feature/Commands/PurgeApplicationsCommandTest.php @@ -0,0 +1,52 @@ +put('applications/documents/cv.pdf', 'pdf'); + Storage::disk('s3')->put('applications/documents/orphan.pdf', 'pdf'); + Storage::disk('s3')->put('applications/exports/bewerbung-1-mira-muster.zip', 'zip'); + + $application = Application::factory()->submitted()->create(); + ApplicationFile::factory()->create([ + 'application_id' => $application->id, + 'path' => 'applications/documents/cv.pdf', + ]); + + DB::table('notifications')->insert([ + 'id' => (string) Str::uuid(), + 'type' => 'App\Notifications\ApplicationSubmittedNotification', + 'notifiable_type' => Application::class, + 'notifiable_id' => $application->id, + 'data' => '{}', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + runArtisan('applications:purge', ['--force' => true])->assertSuccessful(); + + expect(Application::query()->count())->toBe(0) + ->and(ApplicationFile::query()->count())->toBe(0) + ->and(DB::table('notifications')->count())->toBe(0); + + Storage::disk('s3')->assertMissing('applications/documents/cv.pdf'); + Storage::disk('s3')->assertMissing('applications/documents/orphan.pdf'); + Storage::disk('s3')->assertMissing('applications/exports/bewerbung-1-mira-muster.zip'); +})->group('applications'); + +it('aborts without confirmation', function () { + Application::factory()->create(); + + runArtisan('applications:purge') + ->expectsConfirmation('This permanently deletes ALL applications, uploaded documents, related notifications and export zips. Continue?', 'no') + ->assertSuccessful(); + + expect(Application::query()->count())->toBe(1); +})->group('applications'); diff --git a/tests/Feature/Controllers/Jobs/ApplicationRequestControllerTest.php b/tests/Feature/Controllers/Jobs/ApplicationRequestControllerTest.php index aec0fdb..1bb070d 100644 --- a/tests/Feature/Controllers/Jobs/ApplicationRequestControllerTest.php +++ b/tests/Feature/Controllers/Jobs/ApplicationRequestControllerTest.php @@ -3,9 +3,11 @@ declare(strict_types=1); use App\Enums\ApplicationStatusEnum; +use App\Enums\JobPositionStatusEnum; use App\Enums\LocaleEnum; use App\Jobs\Applications\SendApplicationLinkJob; use App\Models\Application; +use App\Models\JobPosition; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Crypt; @@ -13,6 +15,13 @@ use function Pest\Laravel\get; use function Pest\Laravel\post; +beforeEach(function () { + JobPosition::factory()->create([ + 'key' => Application::JOB_KEY_INTERNSHIP, + 'status' => JobPositionStatusEnum::Open, + ]); +}); + it('creates a draft for an unknown email and dispatches the link job', function () { Bus::fake(); @@ -23,6 +32,7 @@ expect($application->email)->toBe('mina@example.com') ->and($application->job_key)->toBe(Application::JOB_KEY_INTERNSHIP) + ->and($application->jobPosition?->key)->toBe(Application::JOB_KEY_INTERNSHIP) ->and($application->status)->toBe(ApplicationStatusEnum::Draft); Bus::assertDispatched(SendApplicationLinkJob::class, function (SendApplicationLinkJob $job) { @@ -168,3 +178,16 @@ Bus::assertNotDispatched(SendApplicationLinkJob::class); assertDatabaseCount('applications', 0); })->group('applications'); + +it('rejects new application requests while the position is in process', function () { + Bus::fake(); + + JobPosition::query()->update(['status' => JobPositionStatusEnum::InProcess]); + + post(route('de-ch.jobs.internship.request.store'), ['email' => 'mina@example.com']) + ->assertRedirect(route('de-ch.jobs.internship.show')) + ->assertSessionHas('status', __('Internship closed teaser')); + + Bus::assertNotDispatched(SendApplicationLinkJob::class); + assertDatabaseCount('applications', 0); +})->group('applications'); diff --git a/tests/Feature/Controllers/Jobs/JobsInternshipShowControllerTest.php b/tests/Feature/Controllers/Jobs/JobsInternshipShowControllerTest.php index 03d41be..c5e8cad 100644 --- a/tests/Feature/Controllers/Jobs/JobsInternshipShowControllerTest.php +++ b/tests/Feature/Controllers/Jobs/JobsInternshipShowControllerTest.php @@ -2,10 +2,23 @@ declare(strict_types=1); +use App\Enums\JobPositionStatusEnum; +use App\Models\Application; use App\Models\Contact; +use App\Models\JobPosition; use function Pest\Laravel\get; +beforeEach(function () { + JobPosition::factory()->create([ + 'key' => Application::JOB_KEY_INTERNSHIP, + 'status' => JobPositionStatusEnum::Open, + 'route_name' => 'jobs.internship.show', + 'title' => ['de_CH' => 'IMS-Praktikum 2027/28', 'en_CH' => 'IMS Internship 2027/28'], + 'teaser' => ['de_CH' => 'Der ganze Weg der Softwareentwicklung.', 'en_CH' => 'The whole journey of software development.'], + ]); +}); + it('renders the internship page for both locales', function (string $routeName) { get(route($routeName))->assertOk(); })->with(['de-ch.jobs.internship.show', 'en-ch.jobs.internship.show'])->group('applications'); @@ -59,12 +72,13 @@ ->assertDontSee(__('Internship team heading')); })->group('applications'); -it('lists the internship as an open position on the jobs page below no other section than open positions', function () { +it('lists the internship as an open position on the jobs page', function () { get(route('de-ch.jobs.index')) ->assertOk() - ->assertSee(__('Internship title')) ->assertSee(route('de-ch.jobs.internship.show')) - ->assertSeeInOrder([__('Jobs open positions heading'), __('Internship title'), __('Jobs spontaneous heading')]); + ->assertSeeInOrder([__('Jobs open positions heading'), 'IMS-Praktikum 2027/28', __('Details and application')]) + ->assertDontSee('Initiativbewerbung') + ->assertDontSee(__('Jobs no open positions')); })->group('applications'); it('carries a job posting schema node', function () { @@ -83,3 +97,40 @@ ->assertOk() ->assertSee(route('de-ch.jobs.internship.show')); })->group('applications'); + +it('shows the closed notice instead of the application form while the position is in process', function () { + JobPosition::query()->update(['status' => JobPositionStatusEnum::InProcess]); + + get(route('de-ch.jobs.internship.show')) + ->assertOk() + ->assertSee(__('Internship closed body')) + ->assertDontSee(__('Internship apply body')); +})->group('applications'); + +it('moves an in-process position out of the open list and marks it with a badge', function () { + JobPosition::query()->update(['status' => JobPositionStatusEnum::InProcess]); + + get(route('de-ch.jobs.index')) + ->assertOk() + ->assertSeeInOrder([__('Jobs training heading'), 'IMS-Praktikum 2027/28', __('Job status in process'), __('Job in process note'), __('Jobs open positions heading'), __('Jobs no open positions')]) + ->assertDontSee(route('de-ch.jobs.internship.show')); +})->group('applications'); + +it('shows the empty state when no positions exist at all', function () { + JobPosition::query()->delete(); + + get(route('de-ch.jobs.index')) + ->assertOk() + ->assertSee(__('Jobs no open positions')) + ->assertDontSee('IMS-Praktikum 2027/28'); +})->group('applications'); + +it('omits the job posting schema while the position is in process', function () { + runArtisan('pages:import')->assertSuccessful(); + + JobPosition::query()->update(['status' => JobPositionStatusEnum::InProcess]); + + get(route('de-ch.jobs.internship.show')) + ->assertOk() + ->assertDontSee('"JobPosting"', false); +})->group('applications'); diff --git a/tests/Feature/E2e/InternshipApplicationFlowTest.php b/tests/Feature/E2e/InternshipApplicationFlowTest.php index 1b651b0..874ce8d 100644 --- a/tests/Feature/E2e/InternshipApplicationFlowTest.php +++ b/tests/Feature/E2e/InternshipApplicationFlowTest.php @@ -3,7 +3,9 @@ declare(strict_types=1); use App\Enums\ApplicationStatusEnum; +use App\Enums\JobPositionStatusEnum; use App\Models\Application; +use App\Models\JobPosition; use App\Notifications\ApplicationLinkNotification; use App\Notifications\ApplicationReceivedNotification; use App\Notifications\ApplicationSubmittedNotification; @@ -20,6 +22,12 @@ Notification::fake(); Storage::fake('s3'); + JobPosition::factory()->create([ + 'key' => Application::JOB_KEY_INTERNSHIP, + 'status' => JobPositionStatusEnum::Open, + 'route_name' => 'jobs.internship.show', + ]); + get(route('de-ch.jobs.index')) ->assertOk() ->assertSee(route('de-ch.jobs.internship.show')); diff --git a/tests/Feature/Jobs/ImportJobPositionsCommandTest.php b/tests/Feature/Jobs/ImportJobPositionsCommandTest.php new file mode 100644 index 0000000..523c644 --- /dev/null +++ b/tests/Feature/Jobs/ImportJobPositionsCommandTest.php @@ -0,0 +1,101 @@ + $files filename => yaml + */ +function writeJobPositionFiles(array $files): string +{ + $base = TempDirectories::next('jobs-import'); + File::ensureDirectoryExists($base); + + foreach ($files as $name => $contents) { + File::put($base.'/'.$name, $contents); + } + + return $base; +} + +function jobPositionYaml(string $key, string $status = 'open', bool $published = true): string +{ + $flag = $published ? 'true' : 'false'; + + return << jobPositionYaml('test-position', 'in-process')]); + + runArtisan('jobs:import', ['--path' => $base])->assertExitCode(0); + + $position = JobPosition::where('key', 'test-position')->firstOrFail(); + + expect($position->published)->toBeTrue() + ->and($position->status)->toBe(JobPositionStatusEnum::InProcess) + ->and($position->route_name)->toBe('jobs.internship.show') + ->and($position->getTranslation('title', 'de_CH'))->toBe('Praktikum DE') + ->and($position->getTranslation('teaser', 'en_CH'))->toBe('Teaser EN'); +})->group('applications'); + +it('removes a position whose file has disappeared', function () { + JobPosition::factory()->create(['key' => 'gone']); + + $base = writeJobPositionFiles(['test-position.yaml' => jobPositionYaml('test-position')]); + + runArtisan('jobs:import', ['--path' => $base])->assertExitCode(0); + + expect(JobPosition::where('key', 'gone')->exists())->toBeFalse() + ->and(JobPosition::where('key', 'test-position')->exists())->toBeTrue(); +})->group('applications'); + +it('skips a position with an unknown status', function () { + $base = writeJobPositionFiles(['test-position.yaml' => jobPositionYaml('test-position', 'filled')]); + + runArtisan('jobs:import', ['--path' => $base])->assertExitCode(1); + + expect(JobPosition::where('key', 'test-position')->exists())->toBeFalse(); +})->group('applications'); + +it('skips a position missing a title language', function () { + $yaml = <<<'YAML' + key: test-position + published: true + status: open + title: + de_CH: 'Nur Deutsch' + YAML; + + $base = writeJobPositionFiles(['test-position.yaml' => $yaml]); + + runArtisan('jobs:import', ['--path' => $base])->assertExitCode(1); + + expect(JobPosition::where('key', 'test-position')->exists())->toBeFalse(); +})->group('applications'); + +it('imports the real content files', function () { + runArtisan('jobs:import')->assertExitCode(0); + + expect(JobPosition::where('key', 'praktikum-ims')->exists())->toBeTrue(); +})->group('applications');