add composer-unused, comprehensive docs, and project restructure
- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks - Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists - Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services - Add Microsoft OIDC SSO, API token management, and user lifecycle features - Add swagger-ui vendor integration and OpenAPI spec - Add production Docker setup and bin/ scripts - Update composer dependencies, config, templates, and frontend assets throughout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
202
lib/Service/Audit/ImportAuditService.php
Normal file
202
lib/Service/Audit/ImportAuditService.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Audit;
|
||||
|
||||
use MintyPHP\Repository\Audit\ImportAuditRunRepository;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
|
||||
class ImportAuditService
|
||||
{
|
||||
private const RETENTION_DAYS = 90;
|
||||
|
||||
/**
|
||||
* @var array<int, float>
|
||||
*/
|
||||
private static array $startedAtByRunId = [];
|
||||
|
||||
public static function startRun(
|
||||
string $profileKey,
|
||||
array $mappedTargets,
|
||||
?string $sourceFilename,
|
||||
int $userId,
|
||||
?int $currentTenantId
|
||||
): ?int {
|
||||
$profileKey = trim($profileKey);
|
||||
if ($profileKey === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$runId = ImportAuditRunRepository::createRunning([
|
||||
'run_uuid' => RepoQuery::uuidV4(),
|
||||
'profile_key' => $profileKey,
|
||||
'status' => 'running',
|
||||
'source_filename' => self::normalizeSourceFilename($sourceFilename),
|
||||
'mapped_targets_csv' => self::normalizeMappedTargetsCsv($mappedTargets),
|
||||
'user_id' => $userId > 0 ? $userId : null,
|
||||
'current_tenant_id' => ($currentTenantId ?? 0) > 0 ? (int) $currentTenantId : null,
|
||||
]);
|
||||
} catch (\Throwable $exception) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$runId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
self::$startedAtByRunId[(int) $runId] = microtime(true);
|
||||
return (int) $runId;
|
||||
}
|
||||
|
||||
public static function finishRun(?int $runId, array $result, ?string $forcedStatus = null): void
|
||||
{
|
||||
$runId = (int) ($runId ?? 0);
|
||||
if ($runId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rowsTotal = max(0, (int) ($result['processed'] ?? 0));
|
||||
$createdCount = max(0, (int) ($result['created'] ?? 0));
|
||||
$skippedCount = max(0, (int) ($result['skipped'] ?? 0));
|
||||
$failedCount = max(0, (int) ($result['failed'] ?? 0));
|
||||
|
||||
$status = self::normalizeStatus($forcedStatus);
|
||||
if ($status === null) {
|
||||
if (array_key_exists('ok', $result) && !($result['ok'] ?? false)) {
|
||||
$status = 'failed';
|
||||
} elseif ($failedCount <= 0) {
|
||||
$status = 'success';
|
||||
} elseif ($createdCount > 0 || $skippedCount > 0) {
|
||||
$status = 'partial';
|
||||
} else {
|
||||
$status = 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
$durationMs = null;
|
||||
if (isset(self::$startedAtByRunId[$runId])) {
|
||||
$durationMs = (int) round((microtime(true) - self::$startedAtByRunId[$runId]) * 1000);
|
||||
if ($durationMs < 0) {
|
||||
$durationMs = 0;
|
||||
}
|
||||
unset(self::$startedAtByRunId[$runId]);
|
||||
}
|
||||
|
||||
$errorCodesJson = self::encodeErrorCounts($result);
|
||||
|
||||
try {
|
||||
ImportAuditRunRepository::finishById($runId, [
|
||||
'status' => $status,
|
||||
'rows_total' => $rowsTotal,
|
||||
'created_count' => $createdCount,
|
||||
'skipped_count' => $skippedCount,
|
||||
'failed_count' => $failedCount,
|
||||
'error_codes_json' => $errorCodesJson,
|
||||
'duration_ms' => $durationMs,
|
||||
]);
|
||||
} catch (\Throwable $exception) {
|
||||
// Fail-open: import flow must not fail because audit logging fails.
|
||||
}
|
||||
}
|
||||
|
||||
public static function listPaged(array $filters): array
|
||||
{
|
||||
return ImportAuditRunRepository::listPaged($filters);
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
return ImportAuditRunRepository::find($id);
|
||||
}
|
||||
|
||||
public static function purgeExpired(): int
|
||||
{
|
||||
return ImportAuditRunRepository::purgeOlderThanDays(self::RETENTION_DAYS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $mappedTargets
|
||||
*/
|
||||
private static function normalizeMappedTargetsCsv(array $mappedTargets): ?string
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($mappedTargets as $target) {
|
||||
$value = trim((string) $target);
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
$normalized[$value] = true;
|
||||
}
|
||||
if (!$normalized) {
|
||||
return null;
|
||||
}
|
||||
$list = array_keys($normalized);
|
||||
sort($list, SORT_STRING);
|
||||
return implode(',', $list);
|
||||
}
|
||||
|
||||
private static function normalizeSourceFilename(?string $sourceFilename): ?string
|
||||
{
|
||||
$sourceFilename = trim((string) ($sourceFilename ?? ''));
|
||||
if ($sourceFilename === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$base = basename(str_replace("\0", '', $sourceFilename));
|
||||
if ($base === '' || $base === '.' || $base === '..') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strlen($base) > 255 ? substr($base, 0, 255) : $base;
|
||||
}
|
||||
|
||||
private static function normalizeStatus(?string $status): ?string
|
||||
{
|
||||
$status = strtolower(trim((string) ($status ?? '')));
|
||||
if ($status === '') {
|
||||
return null;
|
||||
}
|
||||
return in_array($status, ['running', 'success', 'partial', 'failed'], true) ? $status : null;
|
||||
}
|
||||
|
||||
private static function encodeErrorCounts(array $result): ?string
|
||||
{
|
||||
$counts = [];
|
||||
|
||||
$fromResult = $result['error_counts'] ?? null;
|
||||
if (is_array($fromResult)) {
|
||||
foreach ($fromResult as $rawCode => $rawCount) {
|
||||
$code = trim((string) $rawCode);
|
||||
$count = (int) $rawCount;
|
||||
if ($code === '' || $count <= 0) {
|
||||
continue;
|
||||
}
|
||||
$counts[$code] = ($counts[$code] ?? 0) + $count;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$counts) {
|
||||
$errors = $result['errors'] ?? [];
|
||||
if (is_array($errors)) {
|
||||
foreach ($errors as $error) {
|
||||
if (!is_array($error)) {
|
||||
continue;
|
||||
}
|
||||
$code = trim((string) ($error['code'] ?? ''));
|
||||
if ($code === '') {
|
||||
continue;
|
||||
}
|
||||
$counts[$code] = ($counts[$code] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$counts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ksort($counts, SORT_STRING);
|
||||
$encoded = json_encode($counts, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return is_string($encoded) ? $encoded : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user