25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

13 KiB

Kontext: spawntree Timetracker

Wer ich bin

Flo Eisenmenger, Geschäftsführer der spawntree GmbH (kleine Webagentur, Hamburg). DDEV-Entwicklungsumgebung, Symfony 7.x, PHP 8.2, MariaDB 10.11.


Was wir bauen

Ein internes Timetracking-Tool – zunächst nur für mich, später als SaaS. Technisch ähnlich wie mite.de – wir orientieren uns stark am UI/UX von mite.


Tech Stack

  • Backend: Symfony 7, PHP 8.2, Doctrine ORM, MariaDB
  • Frontend: Twig, SCSS (Webpack Encore), Vanilla JS (keine jQuery, kein Framework)
  • SCSS-Struktur: Atoms → Components → Sections (BEM-ähnlich)
  • Dev: DDEV (Port 8456 HTTPS), PHPMyAdmin installiert
  • Kein Symfony Forms – eigene HTML-Formulare mit fetch()-API

Multi-Mandanten-Architektur

Konzept

  • Jeder Account (Firma) bekommt eine eigene Subdomain: spawntree.testtimetracking.ddev.site
  • Jeder Account bekommt eine eigene Tenant-Datenbank: db_spawntree
  • Die Central-DB (db) enthält accountübergreifende Daten: User, Account, AccountUser, Token
  • Die Tenant-DB enthält accountspezifische Daten: Client, Project, Service, TimeEntry

Doctrine: Zwei Connections / Entity Manager

  • centralApp\Entity\Central\* (User, Account, AccountUser, InviteToken, RegistrationToken)
  • tenantApp\Entity\Tenant\* (Client, Project, Service, TimeEntry)
  • Tenant-Connection wechselt DB-Name zur Laufzeit via TenantConnectionMiddleware

Subdomain-Erkennung

  • TenantRequestSubscriber (Priorität 20, vor Firewall) liest den Subdomain-Slug aus dem Host
  • Findet den Account → setzt ihn in TenantContext
  • Kein Account für Slug → Redirect zur Hauptdomain
  • TenantContext ist ein Request-scoped Service, der den aktiven Account hält

Datenbankstruktur

Central-DB: App\Entity\Central

User

  • id, email (unique), firstName, lastName, password (nullable), note
  • Implementiert UserInterface, PasswordAuthenticatedUserInterface
  • getFullName()"Flo Eisenmenger"

Account

  • id, name, slug (unique, → Subdomain), trackingInterval (smallint, default 1)
  • createdAt, superAdminUser (ManyToOne → User, nullable)
  • accountUsers (OneToMany → AccountUser)
  • getTenantDbName()"db_" . str_replace('-', '_', slug)
  • isSuperAdmin(User) → bool
  • getAdmins() → Collection aktiver Admin-AccountUser

AccountUser

  • id, account (ManyToOne), user (ManyToOne), role (admin/member/tracker), archivedAt
  • Konstanten: ROLE_ADMIN, ROLE_MEMBER, ROLE_TRACKER
  • isArchived(), isAdmin(), isMember(), isTracker(), isMemberOrAdmin()
  • getRoleLabel() → Deutsch

InviteToken

  • id, token, account, email, firstName, lastName, role, createdAt
  • isExpired() (7 Tage)

RegistrationToken

  • Für die E-Mail-Bestätigung bei Neuregistrierung

Tenant-DB: App\Entity\Tenant

Client (Kunde)

  • id, name, hourlyRate (decimal, nullable), note
  • Hat viele Projects

Project

  • id, name, client (ManyToOne → Client), note

Service (Leistung)

  • id, name, billable (bool, default true), note

TimeEntry

  • id, date (DATE_IMMUTABLE), duration (int, Minuten), userId (int, kein FK – cross-DB)
  • project (ManyToOne), service (ManyToOne, nullable), note, invoiced (bool, default false)
  • createdAt, updatedAt (via @PreUpdate)
  • toArray() für JSON-Responses (inkl. projectName, clientName, serviceName, serviceBillable, invoiced)
  • getDurationFormatted()"1:30"

Routing-Übersicht

Öffentliche Routen (Hauptdomain)

  • GET /app_home (HomeController – Landing Page oder Redirect)
  • GET /registerapp_register (Registrierungsseite)
  • POST /api/registerapi_register
  • POST /api/register/preview-slugapi_register_preview_slug (Live-Slug-Vorschau)
  • GET /verify/{token}app_verify (E-Mail-Bestätigung)

Auth (Subdomain)

  • GET /loginapp_login
  • GET /logoutapp_logout
  • GET /invite/{token}app_invite (Passwort setzen nach Einladung)

Timetracking

  • GET / → Redirect (je nach Login-Status)
  • GET /weektimetracking_week
  • GET /week/{date}timetracking_week_date
  • GET /api/entries?date=Y-m-d → Einträge für einen Tag (JSON)
  • POST /api/entries → Eintrag erstellen
  • PATCH /api/entries/{id} → Eintrag bearbeiten
  • DELETE /api/entries/{id} → Eintrag löschen
  • PATCH /api/entries/{id}/invoiced → Abrechnungsstatus toggeln

CRUD-Seiten

  • GET /clientsclient_index
  • POST /api/clients, PATCH /api/clients/{id}, DELETE /api/clients/{id}
  • GET /projectsproject_index
  • POST /api/projects, PATCH /api/projects/{id}, DELETE /api/projects/{id}
  • GET /servicesservice_index
  • POST /api/services, PATCH /api/services/{id}, DELETE /api/services/{id}

Reports

  • GET /reports/timesreport_times

Team (nur Admins)

  • GET /teamteam_index
  • POST /api/team/invite → Einladung versenden
  • PATCH /api/team/{id} → Mitglied bearbeiten
  • PATCH /api/team/{id}/archive → Mitglied archivieren
  • PATCH /api/team/{id}/unarchive → Mitglied reaktivieren
  • DELETE /api/team/{id} → Mitglied entfernen
  • DELETE /api/team/invite/{id} → Einladung löschen

Account-Einstellungen

  • GET /accountaccount_index (Tab: account / user, je nach Rolle)
  • PATCH /api/account → Name + trackingInterval (nur Admin)
  • PATCH /api/account/superadmin → Kontoinhaber übertragen (nur aktueller Superadmin)
  • PATCH /api/account/user → Eigene Profildaten / Passwort ändern

Services & Subscribers

  • TenantContext – hält aktiven Account für den Request
  • TenantConnectionMiddleware – wechselt Tenant-DB-Name zur Laufzeit
  • TenantRequestSubscriber – liest Subdomain, setzt TenantContext (Prio 20)
  • ArchivedUserChecker – blockiert Login für archivierte User
  • ArchivedUserSubscriber – wirft Exception bei archivierten Usern während des Requests
  • SlidingSessionSubscriber – verlängert Session bei Aktivität
  • AccountRoleHelperisAdmin(), isMember(), isTracker() für den aktuellen User/Account
  • RegistrationServicestartRegistration(), confirm(token) – erstellt Account + DB + User
  • SlugGenerator – generiert/prüft Slugs aus Firmennamen
  • AppExtension / AppExtensionRuntime – Twig-Funktionen: deMonths(), deMonthsShort(), deWeekdays(), deWeekdaysShort()
  • AccessDeniedHandler – leitet bei 403 auf Login um

Template-Struktur

templates/
├── base.html.twig
├── home/
│   └── index.html.twig              ← Landing Page (Hauptdomain ohne Subdomain)
├── security/
│   └── login.html.twig
├── registration/
│   ├── register.html.twig
│   ├── confirmed.html.twig          ← Nach E-Mail-Bestätigung
│   └── confirm_error.html.twig
├── invite/
│   ├── set_password.html.twig       ← Passwort setzen nach Einladung
│   └── error.html.twig
├── email/
│   ├── team_invite.html.twig
│   ├── registration_welcome.html.twig
│   ├── registration_confirm.html.twig
│   └── registration_notify.html.twig
├── timetracking/
│   ├── week.html.twig               ← Hauptseite Zeiterfassung
│   └── _entry_row.html.twig
├── client/
│   └── index.html.twig
├── project/
│   └── index.html.twig
├── service/
│   └── index.html.twig
├── team/
│   └── index.html.twig              ← Team-Verwaltung (nur Admins)
├── account/
│   └── index.html.twig              ← Account- + Profil-Einstellungen (Tabs)
└── report/
    └── times.html.twig              ← Zeiteinträge-Report

SCSS-Struktur

assets/styles/
├── main.scss                         ← Entry Point
├── atoms/
│   ├── _variables.scss
│   ├── _typography.scss
│   ├── _buttons.scss                 ← .btn, .btn-primary, .btn-secondary
│   └── _inputs.scss                  ← .input, .select, .textarea
├── components/
│   ├── _week-nav.scss
│   ├── _month-calendar.scss
│   ├── _entry-form.scss
│   ├── _entry-list.scss
│   ├── _duration-help.scss
│   ├── _main-nav.scss
│   ├── _greeting.scss
│   ├── _crud.scss                    ← Kunden/Projekte/Leistungen
│   ├── _login.scss
│   ├── _register.scss
│   ├── _team.scss
│   ├── _account.scss
│   └── _report.scss
└── sections/
    ├── _timetracking.scss            ← .tt-page, .tt-header, .tt-content
    └── _home.scss                    ← Landing Page

JS-Struktur

assets/
├── app.js                      ← Entry: importiert main.scss, calendar.js, entries.js
└── scripts/
    ├── calendar.js             ← WeekCalendar (Wochennavigation, Monatsansicht)
    ├── entries.js              ← EntryManager (CRUD, fetch, localStorage)
    ├── duration.js             ← parseDuration(), roundToQuarter(), formatMinutes(),
    │                              validateDuration(), initDurationBlurHandler()
    ├── crud.js                 ← Entry: generisches CRUD (Kunden/Projekte/Leistungen)
    ├── registration.js         ← Entry: Registrierungs-Flow, Live-Slug-Vorschau
    ├── team.js                 ← Entry: Team-Verwaltung
    ├── account.js              ← Entry: Account- + Profil-Einstellungen
    └── report.js               ← Entry: Report-Seite, Edit + Invoiced-Toggle

Webpack Encore Entries

Entry Datei Genutzt auf
app assets/app.js Timetracking-Wochenansicht
crud assets/scripts/crud.js Client/Project/Service-Seiten
registration assets/scripts/registration.js Registrierungsseite
team assets/scripts/team.js Team-Seite
account assets/scripts/account.js Account-Einstellungen
report assets/scripts/report.js Report-Seite

Wichtige Konventionen

Durations

  • Gespeichert als Integer (Minuten) in der DB
  • Eingabe: 1:30, 8 12 (von-bis), 1,75 (Dezimal), 0:00 (Reset)
  • Runden: konfigurierbar per Account.trackingInterval (1, 15, 30, 60 Minuten)
  • Anzeige: formatMinutes()"1:30"

Translations

  • Alle UI-Strings in translations/messages.de.yaml
  • Datums-Arrays in AppExtension.php als Twig-Funktionen: deMonths(), deMonthsShort(), deWeekdays(), deWeekdaysShort()
  • JS bekommt Strings via window.TT.i18n (Timetracking) bzw. window.Report.i18n (Report) etc.
  • JS-Zugriff: function t(key) { return window.X?.i18n?.[key] ?? key; }

API-Pattern

  • Alle API-Routen unter /api/...
  • JSON Request/Response
  • CSRF nur bei form_login (Symfony Security), sonst kein CSRF
  • Fehler: { error: "..." } mit passendem HTTP-Status
  • Validierungsfehler: { errors: ["..."] } mit 422

Rollen

Rolle Kann
admin Alles: Team verwalten, alle Einträge sehen/bearbeiten, Account-Einstellungen
member Eigene Einträge + alle fremden Einträge sehen (kein Team-Zugriff)
tracker Nur eigene Einträge sehen und bearbeiten

Archivierung vs. Löschen (Team)

  • User mit Zeiteinträgen können nur archiviert werden (nicht gelöscht)
  • Archivierte User können sich nicht einloggen (ArchivedUserChecker)
  • Kontoinhaber (superAdminUser) kann nicht archiviert/gelöscht werden

Was noch fehlt / TODO


Seed-Daten

bash 1-reset-and-seed.sh
# → DB droppen, Migrations ausführen, app:seed aufrufen
bash 2-update-tenant-db.sh
# → Tenant-DB-Schema aktualisieren

app:seed legt an: 1 Account (spawntree), 1 Admin-User, 5 Leistungen (4 verrechenbar, 1 intern), 10 Kunden mit je 1–3 Projekten.


DDEV-Konfiguration

  • Projekt: testtimetracking
  • Hauptdomain: https://testtimetracking.ddev.site:8456
  • Tenant-Subdomain Beispiel: https://spawntree.testtimetracking.ddev.site:8456
  • PHPMyAdmin: https://testtimetracking.ddev.site:8037
  • MariaDB: User db, Passwort db, Central-DB db
  • .env: DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
  • APP_DOMAIN=testtimetracking.ddev.site:8456 (für Subdomain-Erkennung und E-Mail-Links)