glanzlicht / MACHWEIS

Mach’s. Beleg’s. Rechne es ab. MACHWEIS ist eine Local-First-Flutter-App für schnelle, kontrollierbare Leistungsnachweise im Handwerk. Die Oberfläche läuft auf iOS, Android und im Web und ist vollständig deutschsprachig.

Verified secure showcase
180 Files 605 KB Public size 26 Redacted Dart · PHP · SQL · Markdown · XML · PLIST

Project overview

MACHWEIS is a verified code showcase with 180 visible project entries across 8 top-level modules. The primary detected language is Dart; protected files remain visible in the structure while their contents stay private.

README

MACHWEIS

Mach’s. Beleg’s. Rechne es ab.

Produktansicht

MACHWEIS Dashboard auf Laptop und Smartphone

MACHWEIS ist eine Local-First-Flutter-App für schnelle, kontrollierbare Leistungsnachweise im Handwerk. Die Oberfläche läuft auf iOS, Android und im Web und ist vollständig deutschsprachig.

Aktueller Funktionsumfang

  • lokale Firmen-, Kunden-, Auftrags- und Mitarbeiterdaten (SQLite auf Android/iOS, Browser-Speicher im Web)
  • mobile Stammdaten-Synchronisation für Kunden, Baustellen, Aufträge, Mitarbeiterprofile und Auftragszuweisungen über die serverseitigen Push-/Pull-Endpunkte
  • atomare lokale Änderungen mit Outbox, Cursor, Retry, sichtbaren Konflikten und einer ausdrücklichen Importentscheidung bei der ersten Verbindung
  • unveränderliche Berichtsversionen mit sichtbarem Prüfverlauf
  • persistente Offline-Synchronisationswarteschlange mit Retry-Strategie
  • lokale Fotodokumentation per Kamera, Galerie oder Web-Dateiauswahl
  • gebrandete PDF-Leistungsnachweise mit Unterschrift, Fotodokumentation und vorbereitetem Teilen per E-Mail
  • prüfpflichtiger KI-Assistent für Baustellennotizen mit OpenAI, Google Gemini oder eigenem Proxy
  • Rollen: Inhaber, Büro, Bauleiter und Monteur
  • strukturierte Arbeitszeit mit Leistungsdatum, Pause und Nettozeit
  • mehrere Leistungs- und Materialpositionen
  • Zusatzaufträge mit Kundenbestätigung
  • Vollständigkeitsprüfung vor Unterschrift
  • digitale Kundenunterschrift und unveränderlicher Abschluss
  • CSV-Export und vorbereitete PDF-Renderer-Schnittstelle
  • responsive Smartphone- und Webnavigation
  • aktuelle App-Version und Build-Nummer dauerhaft am unteren Rand der Navigation

Lokale Entwicklung

flutter pub get
flutter analyze
flutter test
flutter run -d chrome

Produktkontext und technische Leitplanken stehen in [docs/PRODUCT_BRIEF.md](docs/PRODUCT_BRIEF.md). Architektur und Serververtrag der KI-Unterstützung beschreibt [docs/AI_INTEGRATION.md](docs/AI_INTEGRATION.md).

Die Stammdaten-Synchronisation ist ausschließlich für iOS und Android aktiviert. Sie verwendet das Access-Token der mobilen Anmeldung und erneuert es bei Bedarf über die bestehende Authentifizierung. Im Web bleibt die Synchronisation bewusst deaktiviert.

Backend-Grundgerüst

Das separate PHP-8.4-REST-Grundgerüst liegt in [backend/](backend/README.md). Es enthält in diesem Stand ausschließlich Infrastruktur und den Health-Endpunkt; Login, Fachlogik und KI sind noch nicht enthalten.

backend/tests/report_assets_contract.phpPHP
<?php
declare(strict_types=1);
use Machweis\Backend\Auth\TokenService;
use Machweis\Backend\Config;
use Machweis\Backend\Kernel;
use Machweis\Backend\Logger;
use Machweis\Backend\ReportAssetService;
use Machweis\Backend\ReportService;
use Machweis\Backend\Request;
require dirname(__DIR__) . '/src/autoload.php';
function assetCheck(bool $condition, string $message): void
{
if (!$condition) throw new RuntimeException($message);
}
function assetUuid(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 15) | 64);
$bytes[8] = chr((ord($bytes[8]) & 63) | 128);
$hex = bin2hex($bytes);
return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20);
}
/** @param array<string, mixed> $metadata */
function startAsset(Kernel $kernel, string $token, array $metadata): Machweis\Backend\Response
{
return $kernel->handle(new Request('POST', '/api/v1/report-assets/uploads', ['authorization' => 'Bearer ' . $token], json_encode($metadata, JSON_THROW_ON_ERROR)));
}
function putAsset(Kernel $kernel, string $token, string $uploadId, int $offset, string $bytes): Machweis\Backend\Response
{
return $kernel->handle(new Request('PUT', '/api/v1/report-assets/uploads/' . $uploadId, [
'authorization' => 'Bearer ' . $token,
'x-upload-offset' => (string) $offset,
'x-chunk-sha256' => hash('sha256', $bytes),
'content-type' => 'application/octet-stream',
], $bytes));
}
$storage = sys_get_temp_dir() . '/machweis-assets-contract-' . bin2hex(random_bytes(6));
$config = new Config(
'test', false, (string) getenv('DB_HOST'), (int) getenv('DB_PORT'), (string) getenv('DB_NAME'),
(string) getenv('DB_USER'), (string) getenv('DB_PASSWORD'), [], true, str_repeat('assets-contract-secret-', 2),
900, 2592000, $storage, 'asset-cleanup-test', str_repeat('cleanup-contract-secret-', 2),
);
$pdo = new PDO(
sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $config->dbHost, $config->dbPort, $config->dbName),
$config->dbUser,
$config->dbPassword,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false],
);
$tenantA = assetUuid();
$tenantB = assetUuid();
$actor = assetUuid();
$pdo->prepare('INSERT INTO tenants (id,name,status,created_at) VALUES (?, ?, ?, UTC_TIMESTAMP()), (?, ?, ?, UTC_TIMESTAMP())')->execute([$tenantA,'Asset A','active',$tenantB,'Asset B','active']);
$reportService = new ReportService($pdo);
$draft = $reportService->create($tenantA, $actor, ['payload' => ['title' => 'Asset draft']]);
$draftId = (string) $draft['id'];
$tokens = new TokenService($config);
$ownerToken = $tokens->issueAccess(['sub' => $actor, 'tid' => $tenantA, 'role' => 'owner']);
$technicianToken = $tokens->issueAccess(['sub' => assetUuid(), 'tid' => $tenantA, 'role' => 'technician']);
$tenantBToken = $tokens->issueAccess(['sub' => assetUuid(), 'tid' => $tenantB, 'role' => 'owner']);
$kernel = new Kernel($config, new Logger('test'));
$cronPath = '/api/v1/internal/cleanup-assets';
$cronAuthorization = 'Basic ' . base64_encode('asset-cleanup-test:' . str_repeat('cleanup-contract-secret-', 2));
assetCheck($kernel->handle(new Request('GET', $cronPath, []))->status === 401, 'cleanup endpoint must reject missing basic credentials');
assetCheck($kernel->handle(new Request('GET', $cronPath, ['authorization' => 'Basic ' . base64_encode('asset-cleanup-test:wrong')]))->status === 401, 'cleanup endpoint must reject invalid basic credentials');
$png = "\x89PNG\r\n\x1A\n" . str_repeat('asset-data-', 40);
$photoId = assetUuid();
$photoMetadata = [
'assetId' => $photoId,
'reportId' => $draftId,
'reportVersion' => 1,
'kind' => 'photo',
'fileName' => 'beleg.png',
'mimeType' => 'image/png',
'byteSize' => strlen($png),
'sha256' => hash('sha256', $png),
];
$forbidden = startAsset($kernel, $technicianToken, $photoMetadata);
assetCheck($forbidden->status === 403, 'technician must not start asset uploads');
$started = startAsset($kernel, $ownerToken, $photoMetadata);
assetCheck($started->status === 201 && $started->body['data']['offset'] === 0, 'owner must start upload at offset zero');
$uploadId = (string) $started->body['data']['uploadId'];
$first = substr($png, 0, 80);
$second = substr($png, 80);
$progress = putAsset($kernel, $ownerToken, $uploadId, 0, $first);
assetCheck($progress->status === 200 && $progress->body['data']['offset'] === 80, 'first chunk must advance offset');
$replayedChunk = putAsset($kernel, $ownerToken, $uploadId, 0, $first);
assetCheck($replayedChunk->status === 200 && $replayedChunk->body['data']['offset'] === 80, 'identical repeated chunk must be idempotent');
$resumed = startAsset($kernel, $ownerToken, $photoMetadata);
assetCheck($resumed->body['data']['uploadId'] === $uploadId && $resumed->body['data']['offset'] === 80, 'start must resume existing upload');
$completed = putAsset($kernel, $ownerToken, $uploadId, 80, $second);
assetCheck($completed->status === 200 && $completed->body['data']['complete'] === true, 'final chunk must complete asset');
$replayedStart = startAsset($kernel, $ownerToken, $photoMetadata);
assetCheck($replayedStart->body['data']['complete'] === true && $replayedStart->body['data']['asset']['sha256'] === hash('sha256', $png), 'completed upload initialization must be idempotent');
$download = $kernel->handle(new Request('GET', '/api/v1/report-assets/' . $photoId . '/content', ['authorization' => 'Bearer ' . $technicianToken]));
assetCheck($download->status === 200 && $download->binaryBody === $png, 'authorized read must return integrity-checked bytes');
assetCheck(($download->headers['ETag'] ?? '') === '"' . hash('sha256', $png) . '"', 'download must expose immutable checksum');
$isolated = $kernel->handle(new Request('GET', '/api/v1/report-assets/' . $photoId, ['authorization' => 'Bearer ' . $tenantBToken]));
assetCheck($isolated->status === 404, 'other tenant must not observe asset metadata');
$reused = $photoMetadata;
$reused['fileName'] = 'different.png';
assetCheck(startAsset($kernel, $ownerToken, $reused)->status === 409, 'asset id cannot be rebound to different metadata');
$pdf = '%PDF-1.7 test document';
$fileId = assetUuid();
$fileMetadata = ['assetId'=>$fileId,'reportId'=>$draftId,'reportVersion'=>1,'kind'=>'file','fileName'=>'beleg.pdf','mimeType'=>'application/pdf','byteSize'=>strlen($pdf),'sha256'=>hash('sha256',$pdf)];
$fileStart = startAsset($kernel,$ownerToken,$fileMetadata);
putAsset($kernel,$ownerToken,(string)$fileStart->body['data']['uploadId'],0,$pdf);
$deleted = $kernel->handle(new Request('DELETE','/api/v1/report-assets/'.$fileId,['authorization'=>'Bearer '.$ownerToken]));
assetCheck($deleted->status === 204, 'draft asset must be deletable');
assetCheck($kernel->handle(new Request('GET','/api/v1/report-assets/'.$fileId,['authorization'=>'Bearer '.$ownerToken]))->status === 404, 'deleted asset must no longer be readable');
$deletedRow = $pdo->query('SELECT storage_key,content_deleted_at FROM report_assets WHERE id=' . $pdo->quote($fileId))->fetch();
assetCheck(is_array($deletedRow) && $deletedRow['content_deleted_at'] !== null && !is_file($storage . '/' . $deletedRow['storage_key']), 'draft deletion must purge bytes and record physical deletion');
$deleteAudit = $pdo->query('SELECT event_type FROM report_asset_audit_events WHERE asset_id=' . $pdo->quote($fileId) . ' ORDER BY created_at,id')->fetchAll(PDO::FETCH_COLUMN);
sort($deleteAudit);
assetCheck($deleteAudit === ['deleted','uploaded'], 'upload and delete must remain audited');
$signature = json_encode([[0.1,0.2],[0.2,0.3],[-1,-1],[0.3,0.4]], JSON_THROW_ON_ERROR);
$signatureId = assetUuid();
$finalized = $reportService->create($tenantA,$actor,['status'=>'finalized','payload'=>['isFinalized'=>true,'signatureAssetId'=>$signatureId,'signature'=>json_decode($signature,true,512,JSON_THROW_ON_ERROR)]]);
$signatureMetadata = ['assetId'=>$signatureId,'reportId'=>(string)$finalized['id'],'reportVersion'=>1,'kind'=>'signature','fileName'=>'unterschrift.machweis-signature.json','mimeType'=>'application/vnd.machweis.signature+json','byteSize'=>strlen($signature),'sha256'=>hash('sha256',$signature)];
$signatureStart = startAsset($kernel,$ownerToken,$signatureMetadata);
assetCheck(putAsset($kernel,$ownerToken,(string)$signatureStart->body['data']['uploadId'],0,$signature)->body['data']['complete'] === true, 'signature must upload against immutable report version');
$signatureDelete = $kernel->handle(new Request('DELETE','/api/v1/report-assets/'.$signatureId,['authorization'=>'Bearer '.$ownerToken]));
assetCheck($signatureDelete->status === 409 && $signatureDelete->body['error']['code'] === 'finalized_asset_immutable', 'finalized signature must be immutable');
$bad = 'not a png';
$badMetadata = ['assetId'=>assetUuid(),'reportId'=>$draftId,'reportVersion'=>1,'kind'=>'photo','fileName'=>'fake.png','mimeType'=>'image/png','byteSize'=>strlen($bad),'sha256'=>hash('sha256',$bad)];
$badStart = startAsset($kernel,$ownerToken,$badMetadata);
$badResult = putAsset($kernel,$ownerToken,(string)$badStart->body['data']['uploadId'],0,$bad);
assetCheck($badResult->status === 422 && $badResult->body['error']['code'] === 'integrity_failed', 'declared MIME type must match file signature');
$expiredMetadata = $photoMetadata;
$expiredMetadata['assetId'] = assetUuid();
$expiredMetadata['fileName'] = 'expired.png';
$expiredStart = startAsset($kernel,$ownerToken,$expiredMetadata);
$expiredId = (string) $expiredStart->body['data']['uploadId'];
$pdo->prepare('UPDATE report_asset_uploads SET expires_at=DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 MINUTE) WHERE id=?')->execute([$expiredId]);
$cleanupResponse = $kernel->handle(new Request('GET', $cronPath, ['authorization' => $cronAuthorization]));
assetCheck($cleanupResponse->status === 200 && $cleanupResponse->body['data']['expiredUploadsRemoved'] === 1 && (int)$pdo->query('SELECT COUNT(*) FROM report_asset_uploads WHERE id=' . $pdo->quote($expiredId))->fetchColumn() === 0, 'authenticated cleanup endpoint must remove expired resumable sessions');
assetCheck((int)$pdo->query('SELECT COUNT(*) FROM asset_cleanup_runs')->fetchColumn() === 1, 'authenticated cleanup endpoint must persist a successful run marker');
$events = (int) $pdo->query('SELECT COUNT(*) FROM report_asset_audit_events WHERE asset_id=' . $pdo->quote($photoId) . " AND event_type='uploaded' AND sha256=" . $pdo->quote(hash('sha256',$png)))->fetchColumn();
assetCheck($events === 1, 'successful asset must have one checksum-bound audit event');
fwrite(STDOUT, "report_assets_contract: OK\n");

Architecture

High-level modules detected from the verified public snapshot. Protected implementation details are intentionally omitted.

backendmodule
libmodule
iosmodule
testmodule
androidmodule
docsmodule
webmodule
assetsmodule

Technology stack

DartPHPLOCKPBXPROJSQLMarkdownFlutter / Dart
Dart66.7%
PHP14.5%
LOCK4.0%
PBXPROJ3.8%
SQL3.1%
Markdown1.3%
XML0.9%
YAML0.9%