Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
13 changes: 13 additions & 0 deletions app/Actions/ViewDataAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -166,6 +167,18 @@ public function contactsInSection(string $locale, ContactSectionEnum $section):
return $contacts;
}

/**
* @return Collection<int, JobPosition>
*/
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<int, Network>
*/
Expand Down
163 changes: 163 additions & 0 deletions app/Console/Commands/ExportApplicationCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Models\Application;
use App\Models\ApplicationFile;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use ZipArchive;

class ExportApplicationCommand extends Command
{
protected $signature = 'applications:export
{application : The ID of the application to export}
{--dir= : Write the zip to a local directory instead of uploading it to S3}';

protected $description = 'Export an application as a zip with a Markdown summary and all uploaded documents, uploaded to S3 with a signed download URL.';

public function handle(): int
{
$application = Application::query()->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<string, bool> $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;
}
}
140 changes: 140 additions & 0 deletions app/Console/Commands/ImportJobPositionsCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Enums\JobPositionStatusEnum;
use App\Enums\LocaleEnum;
use App\Models\JobPosition;

/**
* Reads one YAML file per position from database/files/jobs/ and writes them to the
* job_positions table. The files are the source of truth — running this repeatedly is safe.
*/
class ImportJobPositionsCommand extends ImportCommand
{
protected $signature = 'jobs:import
{--dry-run : Show what would change without writing anything}
{--path= : Read from this directory instead of database/files/jobs}';

protected $description = 'Import job positions from database/files/jobs/*.yaml';

public function handle(): int
{
$files = $this->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 ? '<fg=yellow>would update</>' : '<fg=green>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, '<fg=green>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<string, string>, teaser: array<string, string>}|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),
];
}
}
1 change: 1 addition & 0 deletions app/Console/Commands/PruneApplicationsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public function handle(): int
$file->deleteFromDisk();
}

$application->deleteExportsFromDisk();
$application->delete();
}

Expand Down
Loading