This commit is contained in:
2026-03-20 15:31:03 +01:00
parent 507aa143ef
commit c4a3f9544a
79 changed files with 1902 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# Implementation Foundation
Das neue Zielprojekt liegt in `saas-app/`, damit der bisherige PHP-Bestand
unangetastet als Referenz bestehen bleibt.
Foundation-Stand:
- Laravel-nahe Ordnerstruktur ohne externe Downloads
- Tenant-Resolution-Skelett ueber Host/Subdomain
- Request-Context-Platzhalter
- Grundrouten fuer Landing, Login und Dashboard
- erste SQL-Migrationsskizzen fuer:
- `tenants`
- `users`
- `tenant_users`
- `roles`
- `tenant_user_roles`
- Blade-Layouts als Platzhalter fuer SSR-Ansatz auf Webspace
Naechste Programmierphase:
1. Identity-/Tenant-Agent auf `saas-app/app` und Auth-/Tenant-Module
2. Ledger-/Core-Agent auf Mitglieder, Striche, Einzahlungen und Dashboard
3. spaeter Operations-Agent fuer Importe, Mail, Umfragen und Exporte
Blocker ausserhalb des Repos:
- `php` lokal nicht installiert
- `composer` lokal nicht installiert
- echtes Laravel-Scaffolding daher noch nicht moeglich
Sobald Tooling vorhanden ist, soll dieses Geruest in ein vollstaendiges
Laravel-Projekt ueberfuehrt werden.
+34
View File
@@ -0,0 +1,34 @@
APP_NAME=KaffeelisteSaaS
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=sqlsrv
DB_HOST=127.0.0.1
DB_PORT=1433
DB_DATABASE=kaffeeliste_saas
DB_USERNAME=saas_user
DB_PASSWORD=
SESSION_DRIVER=file
QUEUE_CONNECTION=database
CACHE_STORE=file
MAIL_MAILER=smtp
MAIL_HOST=127.0.0.1
MAIL_PORT=1025
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM_ADDRESS="noreply@example.test"
MAIL_FROM_NAME="${APP_NAME}"
TENANCY_MODE=subdomain
TENANCY_FALLBACK_TENANT=
TENANCY_CENTRAL_DOMAINS=localhost
OIDC_ENABLED=false
OIDC_DEFAULT_PROVIDER=
+26
View File
@@ -0,0 +1,26 @@
# SaaS App Foundation
Dieses Verzeichnis enthaelt das neue, Laravel-nahe Zielprojekt fuer die
mandantenfaehige SaaS-Version.
Aktueller Stand:
- downloadfreies Foundation-Geruest
- modulare Zielstruktur fuer Webspace-Betrieb
- Tenant-Resolution-Skelett
- Basismigrationen fuer Mandanten, Benutzer und Rollen
- einfache Web-Routen und Blade-Platzhalter
Zielrahmen:
- klassischer Webspace / PHP-Hosting
- SSR-orientiert
- Cron statt dauerhaft laufender Worker
- OIDC zuerst, SAML spaeter optional
Hinweis:
Dieses Geruest ersetzt noch kein vollstaendig installiertes Laravel-Projekt.
Sobald `php` und `composer` verfuegbar sind, soll auf dieser Struktur ein
vollwertiges Laravel-Projekt aufgesetzt oder diese Struktur in ein solches
ueberfuehrt werden.
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Middleware;
use App\Support\RequestContext;
use App\Support\TenantResolver;
class ResolveTenant
{
public function __construct(
private readonly TenantResolver $tenantResolver,
private readonly RequestContext $requestContext
) {
}
public function handle(object $request, callable $next): mixed
{
$host = method_exists($request, 'getHost')
? (string) $request->getHost()
: '';
$tenant = $this->tenantResolver->resolveFromHost($host);
if ($tenant !== null) {
$this->requestContext->setTenant(
$tenant['tenant_id'],
$tenant['tenant_key']
);
}
return $next($request);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Modules\Content\Application;
use App\Modules\Content\Domain\Announcement;
use App\Modules\Content\Domain\FaqItem;
class ContentService
{
/**
* @return array<int, Announcement>
*/
public function activeAnnouncements(string $tenantId): array
{
return [
new Announcement(
id: 'announcement-demo-1',
tenantId: $tenantId,
title: 'Willkommen',
message: 'Das Content-Modul liefert spaeter Hinweise und tenantbezogene Meldungen.',
visibleUntil: '2026-12-31 23:59:59',
active: true,
),
];
}
/**
* @return array<int, FaqItem>
*/
public function faqItems(string $tenantId): array
{
return [
new FaqItem(
id: 'faq-demo-1',
tenantId: $tenantId,
question: 'Wie werden Hinweise gepflegt?',
answer: 'Spaeter ueber den Tenant-Adminbereich mit tenantbezogener Sichtbarkeit.',
sortOrder: 10,
active: true,
),
];
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Modules\Content\Domain;
class Announcement
{
public function __construct(
private readonly string $id,
private readonly string $tenantId,
private readonly string $title,
private readonly string $message,
private readonly string $visibleUntil,
private readonly bool $active,
) {
}
public function id(): string
{
return $this->id;
}
public function tenantId(): string
{
return $this->tenantId;
}
public function title(): string
{
return $this->title;
}
public function message(): string
{
return $this->message;
}
public function visibleUntil(): string
{
return $this->visibleUntil;
}
public function active(): bool
{
return $this->active;
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Modules\Content\Domain;
class FaqItem
{
public function __construct(
private readonly string $id,
private readonly string $tenantId,
private readonly string $question,
private readonly string $answer,
private readonly int $sortOrder,
private readonly bool $active,
) {
}
public function id(): string
{
return $this->id;
}
public function tenantId(): string
{
return $this->tenantId;
}
public function question(): string
{
return $this->question;
}
public function answer(): string
{
return $this->answer;
}
public function sortOrder(): int
{
return $this->sortOrder;
}
public function active(): bool
{
return $this->active;
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Modules\Exports\Application;
use App\Modules\Exports\Jobs\ExportJob;
class ExportService
{
/**
* @return array<int, ExportJob>
*/
public function recentExports(string $tenantId): array
{
return [
new ExportJob(
id: 'export-demo-1',
tenantId: $tenantId,
exportType: 'ledger-csv',
status: 'ready',
targetPath: 'exports/tenant-demo/ledger-2026-03.csv',
),
];
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Modules\Exports\Jobs;
class ExportJob
{
public function __construct(
private readonly string $id,
private readonly string $tenantId,
private readonly string $exportType,
private readonly string $status,
private readonly string $targetPath,
) {
}
public function id(): string
{
return $this->id;
}
public function tenantId(): string
{
return $this->tenantId;
}
public function exportType(): string
{
return $this->exportType;
}
public function status(): string
{
return $this->status;
}
public function targetPath(): string
{
return $this->targetPath;
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity\Controllers;
class ForgotPasswordController
{
public function show(): array
{
return [
'view' => 'auth.forgot-password',
'data' => [
'title' => 'Passwort zuruecksetzen',
],
];
}
public function sendResetLink(array $payload): array
{
return [
'status' => 'queued-placeholder',
'email' => $payload['email'] ?? null,
];
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity\Controllers;
use App\Modules\Identity\Services\AuthService;
class LoginController
{
public function __construct(private readonly AuthService $authService)
{
}
public function show(): array
{
return [
'view' => 'auth.login',
'data' => [
'title' => 'Anmeldung',
'providers' => $this->authService->availableProviders(),
],
];
}
public function authenticate(array $payload): array
{
return $this->authService->attemptLogin($payload);
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity\Controllers;
use App\Modules\Identity\Services\OidcProviderService;
class OidcController
{
public function __construct(private readonly OidcProviderService $providerService)
{
}
public function providers(): array
{
return [
'providers' => $this->providerService->configuredProviders(),
];
}
public function start(string $providerKey): array
{
return [
'status' => 'redirect-placeholder',
'provider' => $providerKey,
];
}
public function callback(string $providerKey, array $claims = []): array
{
return [
'status' => 'callback-placeholder',
'provider' => $providerKey,
'claims' => $claims,
];
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity\Services;
class AuthService
{
public function availableProviders(): array
{
return [
'password' => [
'label' => 'Lokaler Login',
'type' => 'password',
],
'oidc' => [
'label' => 'SSO via OIDC',
'type' => 'oidc',
],
];
}
public function attemptLogin(array $payload): array
{
return [
'status' => 'not-implemented',
'email' => $payload['email'] ?? null,
];
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity\Services;
use App\Modules\Identity\Support\OidcProviderConfig;
class OidcProviderService
{
/**
* @return array<int, OidcProviderConfig>
*/
public function configuredProviders(): array
{
return [
new OidcProviderConfig(
providerKey: 'entra-default',
driver: 'oidc',
clientId: 'tenant-client-id',
redirectUri: '/auth/oidc/entra-default/callback',
scopes: ['openid', 'profile', 'email']
),
];
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity\Support;
class OidcProviderConfig
{
/**
* @param array<int, string> $scopes
*/
public function __construct(
public readonly string $providerKey,
public readonly string $driver,
public readonly string $clientId,
public readonly string $redirectUri,
public readonly array $scopes,
) {
}
public function toArray(): array
{
return [
'provider_key' => $this->providerKey,
'driver' => $this->driver,
'client_id' => $this->clientId,
'redirect_uri' => $this->redirectUri,
'scopes' => $this->scopes,
];
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Modules\Imports\Application;
use App\Modules\Imports\Jobs\ImportJob;
class ImportService
{
/**
* @return array<int, ImportJob>
*/
public function pendingJobs(string $tenantId): array
{
return [
new ImportJob(
id: 'import-demo-1',
tenantId: $tenantId,
type: 'members-csv',
status: 'pending',
scheduledAt: '2026-03-20 12:00:00',
runViaCron: true,
),
];
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Modules\Imports\Jobs;
class ImportJob
{
public function __construct(
private readonly string $id,
private readonly string $tenantId,
private readonly string $type,
private readonly string $status,
private readonly string $scheduledAt,
private readonly bool $runViaCron,
) {
}
public function id(): string
{
return $this->id;
}
public function tenantId(): string
{
return $this->tenantId;
}
public function type(): string
{
return $this->type;
}
public function status(): string
{
return $this->status;
}
public function scheduledAt(): string
{
return $this->scheduledAt;
}
public function runViaCron(): bool
{
return $this->runViaCron;
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Modules\Ledger\Application;
class DashboardService
{
public function summary(string $tenantId, string $memberId): array
{
$ledgerService = new LedgerService();
$balance = $ledgerService->balanceForMember($tenantId, $memberId);
return [
'balance' => $balance,
'coffee_strokes_this_month' => 5,
'payments_this_month' => 1,
'latest_booking_at' => '2026-03-20 10:30:00',
];
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Modules\Ledger\Application;
use App\Modules\Ledger\Domain\LedgerEntry;
class LedgerService
{
/**
* @return array<int, LedgerEntry>
*/
public function recentEntries(string $tenantId, string $memberId): array
{
return [
new LedgerEntry(
id: 'ledger-demo-1',
tenantId: $tenantId,
memberId: $memberId,
entryType: 'payment',
amount: 10.00,
bookedAt: '2026-03-20 09:00:00',
referenceType: 'payment',
referenceId: 'payment-demo-1'
),
new LedgerEntry(
id: 'ledger-demo-2',
tenantId: $tenantId,
memberId: $memberId,
entryType: 'consumption',
amount: -2.50,
bookedAt: '2026-03-20 10:30:00',
referenceType: 'coffee_entry',
referenceId: 'coffee-demo-1'
),
];
}
public function balanceForMember(string $tenantId, string $memberId): float
{
$balance = 0.0;
foreach ($this->recentEntries($tenantId, $memberId) as $entry) {
$balance += $entry->amount();
}
return $balance;
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Modules\Ledger\Domain;
class CoffeeEntry
{
public function __construct(
private readonly string $id,
private readonly string $tenantId,
private readonly string $memberId,
private readonly int $strokes,
private readonly float $unitPrice,
private readonly string $bookedAt
) {
}
public function totalCost(): float
{
return $this->strokes * $this->unitPrice;
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Modules\Ledger\Domain;
class LedgerEntry
{
public function __construct(
private readonly string $id,
private readonly string $tenantId,
private readonly string $memberId,
private readonly string $entryType,
private readonly float $amount,
private readonly string $bookedAt,
private readonly string $referenceType,
private readonly ?string $referenceId = null
) {
}
public function amount(): float
{
return $this->amount;
}
public function entryType(): string
{
return $this->entryType;
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Modules\Members\Application;
use App\Modules\Members\Domain\Member;
class MemberService
{
/**
* @return array<int, Member>
*/
public function listForTenant(string $tenantId): array
{
return [
new Member(
id: 'member-demo-1',
tenantId: $tenantId,
tenantUserId: 'tenant-user-demo-1',
displayName: 'Max Beispiel',
email: 'max@example.com'
),
];
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Modules\Members\Domain;
class Member
{
public function __construct(
private readonly string $id,
private readonly string $tenantId,
private readonly string $tenantUserId,
private readonly string $displayName,
private readonly string $email,
private readonly bool $active = true
) {
}
public function id(): string
{
return $this->id;
}
public function tenantId(): string
{
return $this->tenantId;
}
public function tenantUserId(): string
{
return $this->tenantUserId;
}
public function displayName(): string
{
return $this->displayName;
}
public function email(): string
{
return $this->email;
}
public function isActive(): bool
{
return $this->active;
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Modules\Notifications\Services;
class NotificationService
{
/**
* @return array<int, array<string, string>>
*/
public function plannedNotifications(string $tenantId): array
{
return [
[
'tenant_id' => $tenantId,
'channel' => 'email',
'template' => 'payment-reminder',
'delivery_mode' => 'cron',
'status' => 'planned',
],
];
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Modules\Notifications\Support;
class CronSchedule
{
/**
* @return array<int, array<string, string>>
*/
public function definitions(): array
{
return [
[
'job' => 'imports:run',
'expression' => '*/10 * * * *',
'purpose' => 'Verarbeitet geplante CSV-Importe in kurzen Intervallen.',
],
[
'job' => 'exports:run',
'expression' => '*/15 * * * *',
'purpose' => 'Erstellt Exporte ohne dauerhafte Worker.',
],
[
'job' => 'notifications:dispatch',
'expression' => '*/5 * * * *',
'purpose' => 'Versendet geplante Benachrichtigungen ueber Cron.',
],
];
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Modules\Payments\Application;
use App\Modules\Payments\Domain\Payment;
class PaymentService
{
/**
* @return array<int, Payment>
*/
public function recentPayments(string $tenantId, string $memberId): array
{
return [
new Payment(
id: 'payment-demo-1',
tenantId: $tenantId,
memberId: $memberId,
amount: 10.00,
method: 'manual',
bookedAt: '2026-03-20 09:00:00'
),
];
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Modules\Payments\Domain;
class Payment
{
public function __construct(
private readonly string $id,
private readonly string $tenantId,
private readonly string $memberId,
private readonly float $amount,
private readonly string $method,
private readonly string $bookedAt
) {
}
public function amount(): float
{
return $this->amount;
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Modules\Surveys\Application;
use App\Modules\Surveys\Domain\Survey;
use App\Modules\Surveys\Domain\SurveyQuestion;
class SurveyService
{
/**
* @return array<int, Survey>
*/
public function activeSurveys(string $tenantId): array
{
return [
new Survey(
id: 'survey-demo-1',
tenantId: $tenantId,
title: 'Zufriedenheit mit dem Kaffeeangebot',
status: 'active',
questions: [
new SurveyQuestion(
id: 'survey-question-demo-1',
surveyId: 'survey-demo-1',
question: 'Wie zufrieden bist du mit dem aktuellen Angebot?',
questionType: 'scale',
required: true,
),
],
),
];
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Modules\Surveys\Domain;
class Survey
{
/**
* @param array<int, SurveyQuestion> $questions
*/
public function __construct(
private readonly string $id,
private readonly string $tenantId,
private readonly string $title,
private readonly string $status,
private readonly array $questions,
) {
}
public function id(): string
{
return $this->id;
}
public function tenantId(): string
{
return $this->tenantId;
}
public function title(): string
{
return $this->title;
}
public function status(): string
{
return $this->status;
}
/**
* @return array<int, SurveyQuestion>
*/
public function questions(): array
{
return $this->questions;
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Modules\Surveys\Domain;
class SurveyQuestion
{
public function __construct(
private readonly string $id,
private readonly string $surveyId,
private readonly string $question,
private readonly string $questionType,
private readonly bool $required,
) {
}
public function id(): string
{
return $this->id;
}
public function surveyId(): string
{
return $this->surveyId;
}
public function question(): string
{
return $this->question;
}
public function questionType(): string
{
return $this->questionType;
}
public function required(): bool
{
return $this->required;
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Modules\Tenants\Models;
class Tenant
{
public function __construct(
public readonly string $id,
public readonly string $tenantKey,
public readonly string $name,
public readonly string $status,
) {
}
public function isActive(): bool
{
return $this->status === 'active';
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Modules\Tenants\Models;
class TenantUser
{
/**
* @param array<int, string> $roles
*/
public function __construct(
public readonly string $tenantId,
public readonly string $userId,
public readonly string $status,
public readonly array $roles = [],
) {
}
public function canAccessTenant(): bool
{
return $this->status === 'active';
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Modules\Tenants\Services;
use App\Modules\Tenants\Models\TenantUser;
class TenantMembershipService
{
public function forUser(string $tenantId, string $userId): TenantUser
{
return new TenantUser(
tenantId: $tenantId,
userId: $userId,
status: 'active',
roles: ['tenant_admin']
);
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Modules\Tenants\Services;
use App\Modules\Tenants\Models\Tenant;
use App\Support\TenantResolver;
class TenantService
{
public function __construct(private readonly TenantResolver $tenantResolver)
{
}
public function resolveFromHost(string $host): ?Tenant
{
$resolved = $this->tenantResolver->resolveFromHost($host);
if ($resolved === null) {
return null;
}
return new Tenant(
id: $resolved['tenant_id'] ?? 'tenant-placeholder',
tenantKey: $resolved['tenant_key'],
name: ucfirst((string) $resolved['tenant_key']),
status: 'active'
);
}
/**
* @return array<int, Tenant>
*/
public function listTenants(): array
{
return [
new Tenant('tenant-placeholder', 'demo', 'Demo Tenant', 'active'),
];
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Support;
class RequestContext
{
private ?string $tenantId = null;
private ?string $tenantKey = null;
private ?string $userId = null;
private array $roles = [];
public function setTenant(?string $tenantId, ?string $tenantKey = null): void
{
$this->tenantId = $tenantId;
$this->tenantKey = $tenantKey;
}
public function tenantId(): ?string
{
return $this->tenantId;
}
public function tenantKey(): ?string
{
return $this->tenantKey;
}
public function setUser(?string $userId, array $roles = []): void
{
$this->userId = $userId;
$this->roles = $roles;
}
public function userId(): ?string
{
return $this->userId;
}
public function roles(): array
{
return $this->roles;
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Support;
class TenantResolver
{
public function resolveFromHost(string $host): ?array
{
$parts = explode('.', $host);
if (count($parts) < 3) {
return null;
}
$tenantKey = $parts[0];
if ($tenantKey === 'www' || $tenantKey === 'app') {
return null;
}
return [
'tenant_id' => null,
'tenant_key' => $tenantKey,
];
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
/**
* Platzhalter fuer den spaeteren Laravel-Bootstrap.
*
* Dieses File dokumentiert, welche Foundation-Bausteine bereits vorgesehen sind:
* - zentrale Konfigurationsdateien
* - Tenant-Resolution pro Request
* - Webspace-taugliche Jobs ueber Cron / Queue-Datenbank
* - modulare Fachstruktur unter app/Modules
*/
return [
'status' => 'foundation-skeleton',
'framework' => 'laravel-near',
];
+19
View File
@@ -0,0 +1,19 @@
{
"name": "kaffeeliste/saas-app-foundation",
"description": "Downloadfreies Laravel-nahes Foundation-Geruest fuer die SaaS-Migration.",
"type": "project",
"license": "proprietary",
"require": {
"php": "^8.2"
},
"autoload": {
"psr-4": {
"App\\": "app/"
}
},
"scripts": {
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
]
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
return [
'name' => env('APP_NAME', 'Kaffeeliste SaaS'),
'env' => env('APP_ENV', 'production'),
'url' => env('APP_URL', 'https://app.example.com'),
'timezone' => 'UTC',
'locale' => 'de',
];
+11
View File
@@ -0,0 +1,11 @@
<?php
return [
'mode' => env('TENANCY_MODE', 'subdomain'),
'central_domains' => [
'app.example.com',
'www.app.example.com',
],
'tenant_parameter' => 'tenant',
'default_route' => 'dashboard',
];
@@ -0,0 +1,13 @@
<?php
return <<<'SQL'
CREATE TABLE tenants (
id CHAR(36) NOT NULL PRIMARY KEY,
tenant_key VARCHAR(120) NOT NULL,
name VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE (tenant_key)
);
SQL;
@@ -0,0 +1,14 @@
<?php
return <<<'SQL'
CREATE TABLE users (
id CHAR(36) NOT NULL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NULL,
display_name VARCHAR(255) NOT NULL,
is_platform_admin BIT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE (email)
);
SQL;
@@ -0,0 +1,15 @@
<?php
return <<<'SQL'
CREATE TABLE tenant_users (
id CHAR(36) NOT NULL PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
user_id CHAR(36) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE (tenant_id, user_id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (user_id) REFERENCES users(id)
);
SQL;
@@ -0,0 +1,13 @@
<?php
return <<<'SQL'
CREATE TABLE roles (
id CHAR(36) NOT NULL PRIMARY KEY,
role_key VARCHAR(100) NOT NULL,
name VARCHAR(255) NOT NULL,
scope VARCHAR(50) NOT NULL DEFAULT 'tenant',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE (role_key, scope)
);
SQL;
@@ -0,0 +1,13 @@
<?php
return <<<'SQL'
CREATE TABLE tenant_user_roles (
id CHAR(36) NOT NULL PRIMARY KEY,
tenant_user_id CHAR(36) NOT NULL,
role_id CHAR(36) NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE (tenant_user_id, role_id),
FOREIGN KEY (tenant_user_id) REFERENCES tenant_users(id),
FOREIGN KEY (role_id) REFERENCES roles(id)
);
SQL;
@@ -0,0 +1,18 @@
<?php
return <<<'SQL'
CREATE TABLE members (
id CHAR(36) NOT NULL PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
tenant_user_id CHAR(36) NOT NULL,
display_name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE (tenant_id, email),
UNIQUE (tenant_id, tenant_user_id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (tenant_user_id) REFERENCES tenant_users(id)
);
SQL;
@@ -0,0 +1,10 @@
<?php
return <<<'SQL'
CREATE TABLE password_reset_tokens (
email VARCHAR(255) NOT NULL,
token VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (email)
);
SQL;
@@ -0,0 +1,18 @@
<?php
return <<<'SQL'
CREATE TABLE coffee_entries (
id CHAR(36) NOT NULL PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
member_id CHAR(36) NOT NULL,
strokes INT NOT NULL,
unit_price DECIMAL(10,2) NOT NULL,
total_cost DECIMAL(10,2) NOT NULL,
booking_source VARCHAR(50) NOT NULL DEFAULT 'manual',
booked_at DATETIME NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (member_id) REFERENCES members(id)
);
SQL;
@@ -0,0 +1,24 @@
<?php
return <<<'SQL'
CREATE TABLE tenant_identity_providers (
id CHAR(36) NOT NULL PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
provider_key VARCHAR(120) NOT NULL,
driver VARCHAR(50) NOT NULL DEFAULT 'oidc',
display_name VARCHAR(255) NOT NULL,
client_id VARCHAR(255) NOT NULL,
client_secret_encrypted VARCHAR(1024) NULL,
discovery_url VARCHAR(1024) NULL,
authorization_url VARCHAR(1024) NULL,
token_url VARCHAR(1024) NULL,
userinfo_url VARCHAR(1024) NULL,
redirect_uri VARCHAR(1024) NOT NULL,
scopes TEXT NULL,
is_enabled BIT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE (tenant_id, provider_key),
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
SQL;
@@ -0,0 +1,16 @@
<?php
return <<<'SQL'
CREATE TABLE payment_entries (
id CHAR(36) NOT NULL PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
member_id CHAR(36) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
payment_method VARCHAR(50) NOT NULL DEFAULT 'manual',
booked_at DATETIME NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (member_id) REFERENCES members(id)
);
SQL;
@@ -0,0 +1,17 @@
<?php
return <<<'SQL'
CREATE TABLE user_identities (
id CHAR(36) NOT NULL PRIMARY KEY,
user_id CHAR(36) NOT NULL,
tenant_id CHAR(36) NULL,
provider_key VARCHAR(120) NOT NULL,
external_subject VARCHAR(255) NOT NULL,
email VARCHAR(255) NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE (provider_key, external_subject),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
SQL;
@@ -0,0 +1,18 @@
<?php
return <<<'SQL'
CREATE TABLE ledger_entries (
id CHAR(36) NOT NULL PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
member_id CHAR(36) NOT NULL,
entry_type VARCHAR(50) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
reference_type VARCHAR(100) NOT NULL,
reference_id CHAR(36) NULL,
booked_at DATETIME NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (member_id) REFERENCES members(id)
);
SQL;
@@ -0,0 +1,15 @@
<?php
return <<<'SQL'
CREATE TABLE announcements (
id CHAR(36) NOT NULL PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
visible_until DATETIME NULL,
is_active BIT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
SQL;
@@ -0,0 +1,15 @@
<?php
return <<<'SQL'
CREATE TABLE faq_items (
id CHAR(36) NOT NULL PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
question VARCHAR(255) NOT NULL,
answer TEXT NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
is_active BIT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
SQL;
@@ -0,0 +1,15 @@
<?php
return <<<'SQL'
CREATE TABLE surveys (
id CHAR(36) NOT NULL PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
title VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'draft',
starts_at DATETIME NULL,
ends_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
SQL;
@@ -0,0 +1,15 @@
<?php
return <<<'SQL'
CREATE TABLE survey_questions (
id CHAR(36) NOT NULL PRIMARY KEY,
survey_id CHAR(36) NOT NULL,
question TEXT NOT NULL,
question_type VARCHAR(50) NOT NULL,
is_required BIT NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (survey_id) REFERENCES surveys(id)
);
SQL;
@@ -0,0 +1,16 @@
<?php
return <<<'SQL'
CREATE TABLE survey_answers (
id CHAR(36) NOT NULL PRIMARY KEY,
survey_id CHAR(36) NOT NULL,
question_id CHAR(36) NOT NULL,
tenant_user_id CHAR(36) NOT NULL,
answer_text TEXT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (survey_id) REFERENCES surveys(id),
FOREIGN KEY (question_id) REFERENCES survey_questions(id),
FOREIGN KEY (tenant_user_id) REFERENCES tenant_users(id)
);
SQL;
@@ -0,0 +1,16 @@
<?php
return <<<'SQL'
CREATE TABLE import_jobs (
id CHAR(36) NOT NULL PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
import_type VARCHAR(100) NOT NULL,
source_path VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
scheduled_at DATETIME NULL,
processed_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
SQL;
@@ -0,0 +1,14 @@
<?php
return <<<'SQL'
CREATE TABLE export_jobs (
id CHAR(36) NOT NULL PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
export_type VARCHAR(100) NOT NULL,
target_path VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
SQL;
@@ -0,0 +1,16 @@
<?php
return <<<'SQL'
CREATE TABLE notification_logs (
id CHAR(36) NOT NULL PRIMARY KEY,
tenant_id CHAR(36) NOT NULL,
channel VARCHAR(50) NOT NULL,
template_key VARCHAR(100) NOT NULL,
recipient VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'planned',
sent_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
SQL;
@@ -0,0 +1,14 @@
@extends('layouts.app')
@section('content')
<section>
<h2>Passwort zuruecksetzen</h2>
<p>Platzhalter fuer den webspace-tauglichen Passwort-Reset-Prozess per E-Mail.</p>
<form method="post" action="/forgot-password">
<label for="email">E-Mail</label>
<input id="email" name="email" type="email" autocomplete="email">
<button type="submit">Reset-Link anfordern</button>
</form>
</section>
@endsection
@@ -0,0 +1,28 @@
@extends('layouts.app')
@section('content')
<section>
<h2>Login</h2>
<p>Mandantenbezogener Einstieg fuer lokalen Login oder spaetere SSO-Anmeldung.</p>
<form method="post" action="/login">
<label for="email">E-Mail</label>
<input id="email" name="email" type="email" autocomplete="username">
<label for="password">Passwort</label>
<input id="password" name="password" type="password" autocomplete="current-password">
<button type="submit">Mit Passwort anmelden</button>
</form>
<p><a href="/forgot-password">Passwort vergessen?</a></p>
<section>
<h3>Single Sign-on</h3>
<p>Hier werden spaeter tenantbezogene OIDC-/ADFS-Provider angeboten.</p>
<ul>
<li><a href="/auth/oidc/entra-default">Mit SSO anmelden</a></li>
</ul>
</section>
</section>
@endsection
@@ -0,0 +1,8 @@
@extends('layouts.app')
@section('content')
<section>
<h2>Hinweise und FAQ</h2>
<p>Platzhalter fuer tenantbezogene Hinweise, FAQ-Eintraege und spaetere Redaktionsfunktionen.</p>
</section>
@endsection
@@ -0,0 +1,13 @@
@extends('layouts.app')
@section('content')
<section>
<h2>Dashboard</h2>
<p>Platzhalter fuer Kontostand, Monatsverbrauch und letzte Buchungen.</p>
<ul>
<li>Kontostand: 7,50 EUR</li>
<li>Striche diesen Monat: 5</li>
<li>Einzahlungen diesen Monat: 1</li>
</ul>
</section>
@endsection
@@ -0,0 +1,8 @@
@extends('layouts.app')
@section('content')
<section>
<h2>Exporte</h2>
<p>Platzhalter fuer Reports, Export-Jobs und spaetere Download-Ansichten.</p>
</section>
@endsection
@@ -0,0 +1,8 @@
@extends('layouts.app')
@section('content')
<section>
<h2>Importe</h2>
<p>Platzhalter fuer CSV-Importe, Cron-gesteuerte Verarbeitungen und Statusanzeigen.</p>
</section>
@endsection
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $title ?? 'Kaffeeliste SaaS' }}</title>
</head>
<body>
<header>
<h1>Kaffeeliste SaaS</h1>
<p>Mandantenfaehige Webspace-Zielanwendung</p>
</header>
<main>
@yield('content')
</main>
</body>
</html>
@@ -0,0 +1,8 @@
@extends('layouts.app')
@section('content')
<section>
<h2>Ledger</h2>
<p>Platzhalter fuer Buchungen, Kontostand und Korrekturen.</p>
</section>
@endsection
@@ -0,0 +1,8 @@
@extends('layouts.app')
@section('content')
<section>
<h2>Mitglieder</h2>
<p>Platzhalter fuer tenantbezogene Mitgliederverwaltung und Statusanzeige.</p>
</section>
@endsection
@@ -0,0 +1,8 @@
@extends('layouts.app')
@section('content')
<section>
<h2>Benachrichtigungen</h2>
<p>Platzhalter fuer Mailversand, Versandstatus und Cron-basierte Zustellung.</p>
</section>
@endsection
@@ -0,0 +1,8 @@
@extends('layouts.app')
@section('content')
<section>
<h2>Einzahlungen</h2>
<p>Platzhalter fuer Zahlungseintraege und spaetere Zahlungsreferenzen.</p>
</section>
@endsection
@@ -0,0 +1,8 @@
@extends('layouts.app')
@section('content')
<section>
<h2>Umfragen</h2>
<p>Platzhalter fuer Umfragen, Fragen, Antworten und spaetere Auswertungen.</p>
</section>
@endsection
@@ -0,0 +1,25 @@
@extends('layouts.app')
@section('content')
<section>
<h2>Tenant-Verwaltung</h2>
<p>Platzhalter fuer die spaetere Verwaltung von Mandanten, Domains, Rollen und SSO-Providern.</p>
<table>
<thead>
<tr>
<th>Mandant</th>
<th>Tenant Key</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>Demo Tenant</td>
<td>demo</td>
<td>active</td>
</tr>
</tbody>
</table>
</section>
@endsection
@@ -0,0 +1,8 @@
@extends('layouts.app')
@section('content')
<section>
<h2>Neue SaaS-Plattform</h2>
<p>Dieses Grundgeruest dient als Startpunkt fuer die mandantenfaehige Neuimplementierung.</p>
</section>
@endsection
+55
View File
@@ -0,0 +1,55 @@
<?php
use App\Http\Middleware\ResolveTenant;
return [
[
'method' => 'GET',
'uri' => '/login',
'action' => 'App\\Modules\\Identity\\Controllers\\LoginController@show',
'name' => 'login',
'middleware' => [ResolveTenant::class],
],
[
'method' => 'POST',
'uri' => '/login',
'action' => 'App\\Modules\\Identity\\Controllers\\LoginController@authenticate',
'name' => 'login.attempt',
'middleware' => [ResolveTenant::class],
],
[
'method' => 'GET',
'uri' => '/forgot-password',
'action' => 'App\\Modules\\Identity\\Controllers\\ForgotPasswordController@show',
'name' => 'password.request',
'middleware' => [ResolveTenant::class],
],
[
'method' => 'POST',
'uri' => '/forgot-password',
'action' => 'App\\Modules\\Identity\\Controllers\\ForgotPasswordController@sendResetLink',
'name' => 'password.email',
'middleware' => [ResolveTenant::class],
],
[
'method' => 'GET',
'uri' => '/auth/oidc/providers',
'action' => 'App\\Modules\\Identity\\Controllers\\OidcController@providers',
'name' => 'auth.oidc.providers',
'middleware' => [ResolveTenant::class],
],
[
'method' => 'GET',
'uri' => '/auth/oidc/{provider}',
'action' => 'App\\Modules\\Identity\\Controllers\\OidcController@start',
'name' => 'auth.oidc.start',
'middleware' => [ResolveTenant::class],
],
[
'method' => 'GET',
'uri' => '/auth/oidc/{provider}/callback',
'action' => 'App\\Modules\\Identity\\Controllers\\OidcController@callback',
'name' => 'auth.oidc.callback',
'middleware' => [ResolveTenant::class],
],
];
+34
View File
@@ -0,0 +1,34 @@
<?php
use App\Http\Middleware\ResolveTenant;
return [
[
'method' => 'GET',
'uri' => '/dashboard',
'action' => 'DashboardController@index',
'name' => 'dashboard',
'middleware' => [ResolveTenant::class],
],
[
'method' => 'GET',
'uri' => '/members',
'action' => 'MembersController@index',
'name' => 'members.index',
'middleware' => [ResolveTenant::class],
],
[
'method' => 'GET',
'uri' => '/ledger',
'action' => 'LedgerController@index',
'name' => 'ledger.index',
'middleware' => [ResolveTenant::class],
],
[
'method' => 'GET',
'uri' => '/payments',
'action' => 'PaymentsController@index',
'name' => 'payments.index',
'middleware' => [ResolveTenant::class],
],
];
+41
View File
@@ -0,0 +1,41 @@
<?php
use App\Http\Middleware\ResolveTenant;
return [
[
'method' => 'GET',
'uri' => '/content',
'action' => 'ContentController@index',
'name' => 'content.index',
'middleware' => [ResolveTenant::class],
],
[
'method' => 'GET',
'uri' => '/surveys',
'action' => 'SurveysController@index',
'name' => 'surveys.index',
'middleware' => [ResolveTenant::class],
],
[
'method' => 'GET',
'uri' => '/imports',
'action' => 'ImportsController@index',
'name' => 'imports.index',
'middleware' => [ResolveTenant::class],
],
[
'method' => 'GET',
'uri' => '/exports',
'action' => 'ExportsController@index',
'name' => 'exports.index',
'middleware' => [ResolveTenant::class],
],
[
'method' => 'GET',
'uri' => '/notifications',
'action' => 'NotificationsController@index',
'name' => 'notifications.index',
'middleware' => [ResolveTenant::class],
],
];
+13
View File
@@ -0,0 +1,13 @@
<?php
use App\Http\Middleware\ResolveTenant;
return [
[
'method' => 'GET',
'uri' => '/tenants',
'action' => 'App\\Modules\\Tenants\\Services\\TenantService@listTenants',
'name' => 'tenants.index',
'middleware' => [ResolveTenant::class],
],
];
+27
View File
@@ -0,0 +1,27 @@
<?php
use App\Http\Middleware\ResolveTenant;
return [
[
'method' => 'GET',
'uri' => '/',
'action' => 'LandingController@index',
'name' => 'landing',
'middleware' => [],
],
[
'method' => 'GET',
'uri' => '/login',
'action' => 'Auth\\LoginController@show',
'name' => 'login',
'middleware' => [ResolveTenant::class],
],
[
'method' => 'GET',
'uri' => '/dashboard',
'action' => 'DashboardController@index',
'name' => 'dashboard',
'middleware' => [ResolveTenant::class],
],
];