diff --git a/apps/api/app/Http/Controllers/ReportController.php b/apps/api/app/Http/Controllers/ReportController.php new file mode 100644 index 0000000..81e46a2 --- /dev/null +++ b/apps/api/app/Http/Controllers/ReportController.php @@ -0,0 +1,283 @@ +queryService = $queryService; + } + + public function index(Request $request): JsonResponse + { + $databaseId = $request->query('database_id'); + $tableId = $request->query('table_id'); + $query = Report::query(); + + if ($tableId) { + $query->where('table_id', $tableId); + } elseif ($databaseId) { + $query->whereHas('table', function ($q) use ($databaseId) { + $q->where('database_id', $databaseId); + }); + } + + return response()->json(ReportResource::collection($query->get())); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'table_id' => 'required|uuid|exists:tables,id', + 'name' => 'required|string|max:255', + 'query' => 'nullable|array', + 'query.where' => 'nullable|array', + 'query.sort' => 'nullable|array', + 'query.select' => 'nullable|array', + 'query.select.*' => 'string', + 'query.group_by' => 'nullable|string', + 'layout' => 'nullable|array', + 'layout.fields' => 'nullable|array', + 'layout.fields.*.name' => 'required_with:layout.fields|string', + 'layout.fields.*.visible' => 'sometimes|boolean', + 'layout.fields.*.order' => 'sometimes|integer', + 'layout.group_order' => 'nullable|array', + 'layout.group_order.*' => 'string', + ]); + + $report = Report::create($validated); + + return response()->json(new ReportResource($report), 201); + } + + public function show(Report $report): JsonResponse + { + return response()->json(new ReportResource($report)); + } + + public function update(Request $request, Report $report): JsonResponse + { + $validated = $request->validate([ + 'name' => 'sometimes|required|string|max:255', + 'query' => 'nullable|array', + 'query.where' => 'nullable|array', + 'query.sort' => 'nullable|array', + 'query.select' => 'nullable|array', + 'query.select.*' => 'string', + 'query.group_by' => 'nullable|string', + 'layout' => 'nullable|array', + 'layout.fields' => 'nullable|array', + 'layout.fields.*.name' => 'required_with:layout.fields|string', + 'layout.fields.*.visible' => 'sometimes|boolean', + 'layout.fields.*.order' => 'sometimes|integer', + 'layout.group_order' => 'nullable|array', + 'layout.group_order.*' => 'string', + ]); + + $report->update($validated); + + return response()->json(new ReportResource($report)); + } + + public function destroy(Report $report): JsonResponse + { + $report->delete(); + + return response()->json(null, 204); + } + + public function preview(Request $request): JsonResponse + { + $validated = $request->validate([ + 'table_id' => 'required|uuid|exists:tables,id', + 'query' => 'nullable|array', + 'layout' => 'nullable|array', + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + ]); + + $table = Table::findOrFail($validated['table_id']); + $ast = $validated['query'] ?? []; + $layout = $validated['layout'] ?? null; + $perPage = $validated['per_page'] ?? null; + $page = $validated['page'] ?? null; + + $result = $this->queryService->execute($table, $ast, $perPage, $page, $layout); + + return response()->json($result); + } + + public function execute(Request $request, Report $report): JsonResponse + { + $validated = $request->validate([ + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + ]); + + $table = $report->table; + $ast = $report->query ?? []; + $layout = $report->layout; + $perPage = $validated['per_page'] ?? null; + $page = $validated['page'] ?? null; + + $result = $this->queryService->execute($table, $ast, $perPage, $page, $layout); + + return response()->json($result); + } + + public function exportPdf(Report $report) + { + $table = $report->table; + $ast = $report->query ?? []; + $layout = $report->layout; + + $result = $this->queryService->execute($table, $ast, null, null, $layout); + $filename = 'rapport_'.strtolower(str_replace(' ', '_', $report->name)).'.pdf'; + + return $this->generatePdfResponse($report->name, $result['columns'], $result['groups'], $ast['group_by'] ?? null, $filename, $layout); + } + + public function exportCsv(Report $report) + { + $table = $report->table; + $ast = $report->query ?? []; + $layout = $report->layout; + + $result = $this->queryService->execute($table, $ast, null, null, $layout); + $filename = 'rapport_'.strtolower(str_replace(' ', '_', $report->name)).'.csv'; + + return $this->generateCsvResponse($result['columns'], $result['groups'], $ast['group_by'] ?? null, $filename, $layout); + } + + public function previewPdf(Request $request) + { + $validated = $request->validate([ + 'table_id' => 'required|uuid|exists:tables,id', + 'name' => 'sometimes|required|string|max:255', + 'query' => 'nullable|array', + 'layout' => 'nullable|array', + ]); + + $table = Table::findOrFail($validated['table_id']); + $reportName = $validated['name'] ?? 'Rapport temporaire'; + $ast = $validated['query'] ?? []; + $layout = $validated['layout'] ?? null; + + $result = $this->queryService->execute($table, $ast, null, null, $layout); + $filename = 'rapport_apercu.pdf'; + + return $this->generatePdfResponse($reportName, $result['columns'], $result['groups'], $ast['group_by'] ?? null, $filename, $layout); + } + + public function previewCsv(Request $request) + { + $validated = $request->validate([ + 'table_id' => 'required|uuid|exists:tables,id', + 'query' => 'nullable|array', + 'layout' => 'nullable|array', + ]); + + $table = Table::findOrFail($validated['table_id']); + $ast = $validated['query'] ?? []; + $layout = $validated['layout'] ?? null; + + $result = $this->queryService->execute($table, $ast, null, null, $layout); + $filename = 'rapport_apercu.csv'; + + return $this->generateCsvResponse($result['columns'], $result['groups'], $ast['group_by'] ?? null, $filename, $layout); + } + + private function generatePdfResponse(string $reportName, array $columns, array $groups, ?string $groupBy, string $filename, ?array $layout = null) + { + $view = null; + if ($layout && isset($layout['view_id'])) { + $view = View::with('table.fields')->find($layout['view_id']); + } + + $html = view('reports.pdf', [ + 'reportName' => $reportName, + 'columns' => $columns, + 'groups' => $groups, + 'groupBy' => $groupBy, + 'layout' => $layout, + 'view' => $view, + ])->render(); + + $dompdf = new Dompdf([ + 'isHtml5ParserEnabled' => true, + 'isPhpEnabled' => false, + 'defaultPaperSize' => 'a4', + 'defaultPaperOrientation' => 'portrait', + ]); + $dompdf->loadHtml($html); + $dompdf->render(); + + return response($dompdf->output(), 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'attachment; filename="'.$filename.'"', + ]); + } + + private function generateCsvResponse(array $columns, array $groups, ?string $groupBy, string $filename, ?array $layout = null) + { + $headers = [ + 'Content-Type' => 'text/csv', + 'Content-Disposition' => 'attachment; filename="'.$filename.'"', + ]; + + $callback = function () use ($columns, $groups, $groupBy, $layout) { + $file = fopen('php://output', 'w'); + fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF)); // BOM UTF-8 + + $csvHeaders = []; + $showHeadersOnly = ($layout['show_headers_only'] ?? false) === true; + if ($showHeadersOnly) { + $csvHeaders[] = $groupBy ?? 'Groupe'; + $csvHeaders[] = 'Nombre de fiches'; + } else { + if ($groupBy) { + $csvHeaders[] = $groupBy; + } + foreach ($columns as $col) { + $csvHeaders[] = $col; + } + } + fputcsv($file, $csvHeaders); + + foreach ($groups as $group) { + $groupKey = $group['key']; + if ($showHeadersOnly) { + $row = [$groupKey, count($group['records'])]; + fputcsv($file, $row); + } else { + foreach ($group['records'] as $rec) { + $row = []; + if ($groupBy) { + $row[] = $groupKey; + } + foreach ($columns as $col) { + $val = $rec[$col] ?? null; + $row[] = is_array($val) ? implode(', ', $val) : $val; + } + fputcsv($file, $row); + } + } + } + fclose($file); + }; + + return response()->stream($callback, 200, $headers); + } +} diff --git a/apps/api/app/Http/Controllers/ShareController.php b/apps/api/app/Http/Controllers/ShareController.php new file mode 100644 index 0000000..6d711d0 --- /dev/null +++ b/apps/api/app/Http/Controllers/ShareController.php @@ -0,0 +1,382 @@ +id) + ->orderBy('created_at', 'desc') + ->get() + ->map(function ($share) { + $targetName = 'Inconnu'; + if ($share->target_type === 'record') { + $rec = Record::find($share->target_id); + if ($rec) { + $titleField = $rec->table->fields->first(fn ($f) => ($f->options['is_title'] ?? false) || $f->type === 'title'); + $targetName = $titleField ? ($rec->data[$titleField->name] ?? 'Fiche') : 'Fiche'; + } + } elseif ($share->target_type === 'view') { + $view = View::find($share->target_id); + $targetName = $view ? $view->name : 'Disposition'; + } elseif ($share->target_type === 'report') { + $report = Report::find($share->target_id); + $targetName = $report ? $report->name : 'Rapport'; + } + + return [ + 'id' => $share->id, + 'name' => $share->name, + 'token' => $share->token, + 'target_type' => $share->target_type, + 'target_id' => $share->target_id, + 'target_name' => $targetName, + 'expires_at' => $share->expires_at ? $share->expires_at->toIso8601String() : null, + 'created_at' => $share->created_at->toIso8601String(), + 'is_expired' => $share->isExpired(), + ]; + }); + + return response()->json($shares); + } + + /** + * Create a new share link. + */ + public function store(Request $request, Database $database): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'target_type' => 'required|string|in:record,view,report', + 'target_id' => 'required|string', + 'expires_at' => 'nullable|date|after:now', + ]); + + // Validate target exists and belongs to database context + if ($validated['target_type'] === 'record') { + $record = Record::findOrFail($validated['target_id']); + if ($record->table->database_id !== $database->id) { + abort(403, 'Record does not belong to this database.'); + } + } elseif ($validated['target_type'] === 'view') { + $view = View::findOrFail($validated['target_id']); + if ($view->table->database_id !== $database->id) { + abort(403, 'View does not belong to this database.'); + } + } elseif ($validated['target_type'] === 'report') { + $report = Report::findOrFail($validated['target_id']); + if ($report->table->database_id !== $database->id) { + abort(403, 'Report does not belong to this database.'); + } + } + + $share = Share::create([ + 'database_id' => $database->id, + 'token' => Str::random(40), + 'name' => $validated['name'], + 'target_type' => $validated['target_type'], + 'target_id' => $validated['target_id'], + 'expires_at' => $validated['expires_at'] ?? null, + ]); + + return response()->json($share, 201); + } + + /** + * Revoke (delete) a share link. + */ + public function destroy(Share $share): JsonResponse + { + $share->delete(); + + return response()->json(null, 204); + } + + /** + * Public show endpoint. + */ + public function show(string $token): JsonResponse + { + $share = Share::where('token', $token)->firstOrFail(); + if ($share->isExpired()) { + return response()->json(['error' => 'Share link expired'], 410); + } + + $database = $share->database; + + if ($share->target_type === 'record') { + $record = Record::findOrFail($share->target_id); + if ($record->trashed()) { + abort(404, 'Shared record has been deleted.'); + } + $table = $record->table; + $resolvedData = $this->resolveRecordReferences($record); + + return response()->json([ + 'target_type' => 'record', + 'name' => $share->name, + 'database_name' => $database->name, + 'table_name' => $table->name, + 'record' => [ + 'id' => $record->id, + 'data' => $resolvedData, + ], + 'fields' => $table->fields, + ]); + } + + if ($share->target_type === 'view') { + $view = View::findOrFail($share->target_id); + $table = $view->table; + $params = [ + 'per_page' => 100, + ]; + $result = $this->recordQueryService->queryRecords($table, $params); + $resolvedRecords = collect($result['data'])->map(function ($record) { + return [ + 'id' => $record->id, + 'data' => $this->resolveRecordReferences($record), + 'created_at' => $record->created_at, + 'updated_at' => $record->updated_at, + ]; + }); + + return response()->json([ + 'target_type' => 'view', + 'name' => $share->name, + 'database_name' => $database->name, + 'table_name' => $table->name, + 'view' => $view, + 'records' => $resolvedRecords, + 'fields' => $table->fields, + ]); + } + + if ($share->target_type === 'report') { + $report = Report::findOrFail($share->target_id); + $table = $report->table; + $ast = $report->query ?? []; + $layout = $report->layout; + + // If headers only, disable pagination parameters + $perPage = null; + $page = null; + if (! $layout || ! ($layout['show_headers_only'] ?? false)) { + $perPage = 100; + $page = 1; + } + + $result = $this->reportQueryService->execute($table, $ast, $perPage, $page, $layout); + + return response()->json([ + 'target_type' => 'report', + 'name' => $share->name, + 'database_name' => $database->name, + 'table_name' => $table->name, + 'report' => $report, + 'result' => $result, + 'fields' => $table->fields, + ]); + } + + abort(400, 'Invalid share target type.'); + } + + /** + * Serve uploads with scope protection and EXIF stripping. + */ + public function serveUpload(string $token, string $hash) + { + $share = Share::where('token', $token)->firstOrFail(); + if ($share->isExpired()) { + abort(410, 'Share expired'); + } + + if (! $this->validateUploadScope($share, $hash)) { + abort(403, 'Unauthorized file scope'); + } + + $path = "uploads/{$hash}"; + if (! Storage::exists($path)) { + abort(404, 'File not found'); + } + + $mimeType = Storage::mimeType($path); + + if ($mimeType && str_starts_with($mimeType, 'image/')) { + $realPath = Storage::path($path); + $strippedPath = $this->stripExif($realPath, $mimeType); + + if ($strippedPath !== $realPath) { + return response()->file($strippedPath, [ + 'Content-Type' => $mimeType, + 'Cache-Control' => 'no-store, no-cache, must-revalidate', + ])->deleteFileAfterSend(true); + } + } + + return Storage::response($path, null, [ + 'Content-Type' => $mimeType, + ]); + } + + /** + * Serve thumbnails securely. + */ + public function serveUploadThumbnail(string $token, string $hash) + { + $share = Share::where('token', $token)->firstOrFail(); + if ($share->isExpired()) { + abort(410, 'Share expired'); + } + + if (! $this->validateUploadScope($share, $hash)) { + abort(403, 'Unauthorized file scope'); + } + + $path = "uploads/thumbnails/{$hash}"; + if (! Storage::exists($path)) { + abort(404, 'Thumbnail not found'); + } + + $mimeType = Storage::mimeType($path); + + return Storage::response($path, null, [ + 'Content-Type' => $mimeType, + 'Cache-Control' => 'public, max-age=31536000', + ]); + } + + /** + * Validate if the upload hash is within the shared target dataset scope. + */ + private function validateUploadScope(Share $share, string $hash): bool + { + if ($share->target_type === 'record') { + $record = Record::find($share->target_id); + if (! $record || $record->trashed()) { + return false; + } + + return str_contains(json_encode($record->data), $hash); + } + + if ($share->target_type === 'view') { + $view = View::find($share->target_id); + if (! $view) { + return false; + } + + return Record::where('table_id', $view->table_id) + ->whereRaw('data::text LIKE ?', ["%{$hash}%"]) + ->exists(); + } + + if ($share->target_type === 'report') { + $report = Report::find($share->target_id); + if (! $report) { + return false; + } + + return Record::where('table_id', $report->table_id) + ->whereRaw('data::text LIKE ?', ["%{$hash}%"]) + ->exists(); + } + + return false; + } + + /** + * Strip EXIF metadata on the fly using GD. + */ + private function stripExif(string $sourcePath, string $mimeType): string + { + if (! in_array($mimeType, ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'], true)) { + return $sourcePath; + } + + try { + $sourceImage = match ($mimeType) { + 'image/jpeg', 'image/jpg' => @imagecreatefromjpeg($sourcePath), + 'image/png' => @imagecreatefrompng($sourcePath), + 'image/webp' => @imagecreatefromwebp($sourcePath), + default => null, + }; + + if (! $sourceImage) { + return $sourcePath; + } + + $tempFile = tempnam(sys_get_temp_dir(), 'exifstrip'); + $success = match ($mimeType) { + 'image/jpeg', 'image/jpg' => imagejpeg($sourceImage, $tempFile, 95), + 'image/png' => imagepng($sourceImage, $tempFile, 9), + 'image/webp' => imagewebp($sourceImage, $tempFile, 95), + default => false, + }; + + imagedestroy($sourceImage); + + return $success ? $tempFile : $sourcePath; + } catch (\Throwable $e) { + return $sourcePath; + } + } + + /** + * Resolve reference IDs to display names for public share view. + */ + private function resolveRecordReferences(Record $record): array + { + $data = $record->data; + $table = $record->table; + + foreach ($table->fields as $field) { + if ($field->type === 'reference' && ! empty($data[$field->name])) { + $refId = $data[$field->name]; + if (is_array($refId)) { + $labels = []; + foreach ($refId as $id) { + $targetRec = Record::find($id); + if ($targetRec) { + $titleField = $targetRec->table->fields->first(fn ($f) => ($f->options['is_title'] ?? false) || $f->type === 'title'); + $labels[] = $titleField ? ($targetRec->data[$titleField->name] ?? $id) : $id; + } else { + $labels[] = $id; + } + } + $data[$field->name] = $labels; + } else { + $targetRec = Record::find($refId); + if ($targetRec) { + $titleField = $targetRec->table->fields->first(fn ($f) => ($f->options['is_title'] ?? false) || $f->type === 'title'); + $data[$field->name] = $titleField ? ($targetRec->data[$titleField->name] ?? $refId) : $refId; + } + } + } + } + + return $data; + } +} diff --git a/apps/api/app/Http/Resources/ReportResource.php b/apps/api/app/Http/Resources/ReportResource.php new file mode 100644 index 0000000..53e03f3 --- /dev/null +++ b/apps/api/app/Http/Resources/ReportResource.php @@ -0,0 +1,27 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'table_id' => $this->table_id, + 'name' => $this->name, + 'query' => $this->query, + 'layout' => $this->layout, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/apps/api/app/Models/Share.php b/apps/api/app/Models/Share.php new file mode 100644 index 0000000..eda63a1 --- /dev/null +++ b/apps/api/app/Models/Share.php @@ -0,0 +1,48 @@ + 'datetime', + ]; + + protected static function boot() + { + parent::boot(); + + static::creating(function ($model) { + if (empty($model->id)) { + $model->id = (string) Str::uuid(); + } + }); + } + + public function database(): BelongsTo + { + return $this->belongsTo(Database::class); + } + + public function isExpired(): bool + { + return $this->expires_at && $this->expires_at->isPast(); + } +} diff --git a/apps/api/app/Services/ReportQueryService.php b/apps/api/app/Services/ReportQueryService.php new file mode 100644 index 0000000..5889fca --- /dev/null +++ b/apps/api/app/Services/ReportQueryService.php @@ -0,0 +1,267 @@ +, groups: list}>} */ + public function execute(Table $table, array $ast, ?int $perPage = null, ?int $page = null, ?array $layout = null): array + { + $table->loadMissing('fields'); + $query = Record::query()->where('table_id', $table->id)->with('linksFrom.toRecord'); + if (isset($ast['where'])) { + $this->applyGroup($query, $table, $ast['where']); + } + + foreach ($ast['sort'] ?? [] as $sort) { + $field = $this->field($table, $sort['field'] ?? ''); + if ($field) { + $query->orderByRaw("data->>'{$field->name}' ".(($sort['direction'] ?? 'asc') === 'desc' ? 'desc' : 'asc')); + } + } + + $columns = array_values($ast['select'] ?? $table->fields->pluck('name')->all()); + $groupBy = $ast['group_by'] ?? null; + + if ($perPage !== null) { + $records = $query->paginate($perPage, ['*'], 'page', $page); + $collection = $records->getCollection(); + } else { + $collection = $query->get(); + $records = null; + } + + $groups = $collection->groupBy(function (Record $record) use ($groupBy, $table) { + if (! $groupBy) { + return ''; + } + $val = $this->resolveValue($record, $table, $groupBy); + if (is_array($val)) { + return implode(', ', $val); + } + + return (string) ($val ?? 'Inconnu'); + }) + ->map(fn ($records, $key) => [ + 'key' => (string) $key, + 'records' => $records->map(fn (Record $record) => $this->project($record, $table, $columns))->values()->all(), + ]) + ->values()->all(); + + if ($layout) { + $layoutFields = $layout['fields'] ?? []; + if (! empty($layoutFields)) { + usort($layoutFields, fn ($a, $b) => ($a['order'] ?? 0) <=> ($b['order'] ?? 0)); + $visibleColumns = []; + foreach ($layoutFields as $lf) { + if ($lf['visible'] ?? true) { + $visibleColumns[] = $lf['name']; + } + } + if (! empty($visibleColumns)) { + $columns = $visibleColumns; + foreach ($groups as &$group) { + foreach ($group['records'] as &$record) { + $orderedRecord = ['id' => $record['id']]; + foreach ($columns as $col) { + $orderedRecord[$col] = $record[$col] ?? null; + } + $record = $orderedRecord; + } + } + } + } + + $groupOrder = $layout['group_order'] ?? null; + if (is_array($groupOrder) && ! empty($groupOrder)) { + usort($groups, function ($a, $b) use ($groupOrder) { + $posA = array_search($a['key'], $groupOrder); + $posB = array_search($b['key'], $groupOrder); + $posA = $posA === false ? 999999 : $posA; + $posB = $posB === false ? 999999 : $posB; + + return $posA <=> $posB; + }); + } + } + + $result = ['columns' => $columns, 'groups' => $groups]; + + if ($records) { + $result['pagination'] = [ + 'current_page' => $records->currentPage(), + 'per_page' => $records->perPage(), + 'total' => $records->total(), + 'last_page' => $records->lastPage(), + ]; + } + + return $result; + } + + private function applyGroup(Builder $query, Table $table, array $group, string $boolean = 'and'): void + { + $logic = strtolower($group['logic'] ?? 'and') === 'or' ? 'or' : 'and'; + $method = $boolean === 'or' ? 'orWhere' : 'where'; + $query->{$method}(function (Builder $nested) use ($table, $group, $logic) { + foreach ($group['conditions'] ?? [] as $condition) { + if (isset($condition['conditions'])) { + $this->applyGroup($nested, $table, $condition, $logic); + } else { + $this->applyCondition($nested, $table, $condition, $logic); + } + } + }); + } + + private function applyCondition(Builder $query, Table $table, array $condition, string $boolean = 'and'): void + { + $fieldName = $condition['field'] ?? ''; + $operator = $condition['operator'] ?? 'eq'; + $value = $condition['value'] ?? null; + if (str_contains($fieldName, '.')) { + [$reference, $target] = explode('.', $fieldName, 2); + $field = $this->field($table, $reference); + if (! $field || $field->type !== 'reference') { + return; + } + $method = $boolean === 'or' ? 'orWhereHas' : 'whereHas'; + $query->{$method}('linksFrom', fn (Builder $links) => $links->where('field_id', $field->id)->whereHas('toRecord', fn (Builder $records) => $this->scalar($records, $target, $operator, $value))); + + return; + } + if ($field = $this->field($table, $fieldName)) { + $this->scalar($query, $field->name, $operator, $value, $boolean); + } + } + + private function scalar(Builder $query, string $field, string $operator, mixed $value, string $boolean = 'and'): void + { + $path = "data->>'{$field}'"; + $prefix = $boolean === 'or' ? 'orWhere' : 'where'; + match ($operator) { + 'contains' => $query->{$prefix.'Raw'}("{$path} LIKE ?", ["%{$value}%"]), + 'neq' => $query->{$prefix.'Raw'}("{$path} != ?", [$value]), + 'gt', 'gte', 'lt', 'lte' => $query->{$prefix.'Raw'}("CAST({$path} AS NUMERIC) ".match ($operator) { + 'gt' => '>', 'gte' => '>=', 'lt' => '<', 'lte' => '<=' + }.' ?', [$value]), + 'is_null' => $query->{$prefix.'Null'}("data->{$field}"), + default => $query->{$prefix.'Raw'}("{$path} = ?", [$value]), + }; + } + + private function project(Record $record, Table $table, array $columns): array + { + $row = ['id' => $record->id]; + foreach ($columns as $column) { + $row[$column] = $this->resolveValue($record, $table, $column); + } + + return $row; + } + + private function resolveValue(Record $record, Table $table, string $column): mixed + { + if (str_contains($column, '.')) { + [$referenceName, $fieldName] = explode('.', $column, 2); + $field = $this->field($table, $referenceName); + if ($field && $field->type === 'reference') { + $links = $record->linksFrom->where('field_id', $field->id); + $isMulti = ($field->options['multi'] ?? false) === true; + if ($isMulti) { + return $links->map(function ($link) use ($fieldName) { + $toRecord = $link->toRecord; + if ($toRecord) { + if (in_array(strtolower($fieldName), ['id', 'created_at', 'updated_at', 'table_id', 'version'])) { + return $toRecord->{strtolower($fieldName)}; + } + + $targetTable = $toRecord->table; + if ($targetTable) { + $targetField = $targetTable->fields->first(fn ($f) => strcasecmp($f->name, $fieldName) === 0); + if ($targetField) { + return $toRecord->data[$targetField->name] ?? null; + } + } + + return $toRecord->data[$fieldName] ?? null; + } + + return null; + })->filter(fn ($val) => ! is_null($val))->values()->all(); + } else { + $link = $links->first(); + if ($link && $link->toRecord) { + $toRecord = $link->toRecord; + if (in_array(strtolower($fieldName), ['id', 'created_at', 'updated_at', 'table_id', 'version'])) { + return $toRecord->{strtolower($fieldName)}; + } + + $targetTable = $toRecord->table; + if ($targetTable) { + $targetField = $targetTable->fields->first(fn ($f) => strcasecmp($f->name, $fieldName) === 0); + if ($targetField) { + return $toRecord->data[$targetField->name] ?? null; + } + } + + return $toRecord->data[$fieldName] ?? null; + } + + return null; + } + } + } + + $field = $this->field($table, $column); + if ($field) { + if ($field->type === 'reference') { + $links = $record->linksFrom->where('field_id', $field->id); + $isMulti = ($field->options['multi'] ?? false) === true; + $targetTableId = $field->options['target_table'] ?? null; + if ($targetTableId) { + $targetTable = Table::with('fields')->find($targetTableId); + if ($targetTable) { + $titleField = $targetTable->fields->first(fn ($f) => ($f->options['is_title'] ?? false) === true) + ?? $targetTable->fields->firstWhere('type', 'title'); + $titleFieldName = $titleField ? $titleField->name : null; + + if ($isMulti) { + return $links->map(function ($link) use ($titleFieldName) { + $toRecord = $link->toRecord; + if ($toRecord) { + return $titleFieldName ? ($toRecord->data[$titleFieldName] ?? null) : $toRecord->id; + } + + return null; + })->filter(fn ($val) => ! is_null($val))->values()->all(); + } else { + $link = $links->first(); + if ($link && $link->toRecord) { + $toRecord = $link->toRecord; + + return $titleFieldName ? ($toRecord->data[$titleFieldName] ?? null) : $toRecord->id; + } + + return null; + } + } + } + } + + return $record->data[$field->name] ?? null; + } + + return $record->data[$column] ?? null; + } + + private function field(Table $table, string $name): ?Field + { + return $table->fields->first(fn ($f) => strcasecmp($f->name, $name) === 0); + } +} diff --git a/apps/api/composer.json b/apps/api/composer.json index 35f886c..5bdfe5b 100644 --- a/apps/api/composer.json +++ b/apps/api/composer.json @@ -7,6 +7,7 @@ "license": "MIT", "require": { "php": "^8.3", + "dompdf/dompdf": "^3.1", "laravel/framework": "^13.8", "laravel/sanctum": "^4.0", "laravel/tinker": "^3.0", diff --git a/apps/api/composer.lock b/apps/api/composer.lock index 8279a2b..526e0d7 100644 --- a/apps/api/composer.lock +++ b/apps/api/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fa4547b6ac5fd55cd681de1e3f637bbd", + "content-hash": "34ff9c6b681ad022c178e46c00443686", "packages": [ { "name": "brick/math", @@ -376,6 +376,161 @@ ], "time": "2024-02-05T11:56:58+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.5", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" + }, + "time": "2026-03-03T13:54:37+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.6.0", @@ -2092,6 +2247,73 @@ ], "time": "2026-03-08T20:05:35+00:00" }, + { + "name": "masterminds/html5", + "version": "2.10.1", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" + }, + "time": "2026-06-23T18:43:15+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -3366,6 +3588,86 @@ }, "time": "2026-06-18T03:57:49+00:00" }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.4.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.33 || 2.2.2", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.16", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11", + "phpunit/phpunit": "8.5.52", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.4.6", + "rector/type-perfect": "1.0.0 || 2.1.3", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.5.x-dev" + } + }, + "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0" + }, + "time": "2026-06-18T15:10:53+00:00" + }, { "name": "symfony/clock", "version": "v8.1.0", @@ -5845,6 +6147,149 @@ ], "time": "2026-06-09T10:54:51+00:00" }, + { + "name": "thecodingmachine/safe", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2026-02-04T18:08:13+00:00" + }, { "name": "tijsverkoyen/css-to-inline-styles", "version": "v2.4.0", diff --git a/apps/api/database/migrations/2026_07_12_011802_create_shares_table.php b/apps/api/database/migrations/2026_07_12_011802_create_shares_table.php new file mode 100644 index 0000000..12f56ed --- /dev/null +++ b/apps/api/database/migrations/2026_07_12_011802_create_shares_table.php @@ -0,0 +1,33 @@ +uuid('id')->primary(); + $table->foreignUuid('database_id')->constrained()->onDelete('cascade'); + $table->string('token')->unique(); + $table->string('name'); + $table->string('target_type'); // 'record', 'view', 'report' + $table->string('target_id')->index(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shares'); + } +}; diff --git a/apps/api/database/seeders/TemplateSeeder.php b/apps/api/database/seeders/TemplateSeeder.php index 4718fe9..11eddd7 100644 --- a/apps/api/database/seeders/TemplateSeeder.php +++ b/apps/api/database/seeders/TemplateSeeder.php @@ -148,7 +148,31 @@ public function run(): void ['key' => 'localisation', 'name' => 'Localisation', 'type' => 'reference', 'position' => 14, 'options' => ['target_table' => 'locations', 'multi' => false]], ], 'views' => [], - 'reports' => [], + 'reports' => [ + [ + 'key' => 'annexe-b', + 'name' => 'Annexe B', + 'query' => [ + 'select' => ['annee_publication', 'auteur.nom', 'auteur.prenom', 'titre', 'nombre_pages', 'imprimeur.nom', 'editeur.nom'], + 'group_by' => 'annee_publication', + 'sort' => [ + ['field' => 'annee_publication', 'direction' => 'asc'], + ['field' => 'titre', 'direction' => 'asc'], + ], + ], + 'layout' => [ + 'fields' => [ + ['name' => 'annee_publication', 'visible' => true, 'order' => 1], + ['name' => 'auteur.nom', 'visible' => true, 'order' => 2], + ['name' => 'auteur.prenom', 'visible' => true, 'order' => 3], + ['name' => 'titre', 'visible' => true, 'order' => 4], + ['name' => 'nombre_pages', 'visible' => true, 'order' => 5], + ['name' => 'imprimeur.nom', 'visible' => true, 'order' => 6], + ['name' => 'editeur.nom', 'visible' => true, 'order' => 7], + ], + ], + ], + ], ], [ 'key' => 'issues', diff --git a/apps/api/resources/views/reports/pdf.blade.php b/apps/api/resources/views/reports/pdf.blade.php new file mode 100644 index 0000000..1c38b60 --- /dev/null +++ b/apps/api/resources/views/reports/pdf.blade.php @@ -0,0 +1,213 @@ + + + + + {{ $reportName }} + + + +

{{ $reportName }}

+ +
+ + + + + +
+ Généré le : {{ now()->format('d/m/Y') }} + + Format : PDF Officiel HAP +
+
+ + @foreach ($groups as $group) +
+ @if ($group['key']) +
{{ $groupBy }} : {{ $group['key'] }} ({{ count($group['records']) }})
+ @endif + @if (!($layout['show_headers_only'] ?? false)) + @if ($view && isset($view->config['columns'])) + @foreach ($group['records'] as $rec) + @php + $titleField = $view->table->fields->first(fn($f) => ($f->options['is_title'] ?? false) || $f->type === 'title'); + $titleVal = null; + if ($titleField) { + foreach ($rec as $k => $v) { + if (strcasecmp($k, $titleField->name) === 0) { + $titleVal = $v; + break; + } + } + } + $columnsLayout = $view->config['columns']; + $colCount = count($columnsLayout); + $colWidth = $colCount > 1 ? '48%' : '100%'; + @endphp +
+ @if ($titleVal) +
{{ $titleVal }}
+ @endif + + + @foreach ($columnsLayout as $colFields) + + @endforeach + +
+
+ @foreach ($colFields as $fId) + @php + $cleanId = str_starts_with($fId, 'draft-') ? substr($fId, 6) : $fId; + $fieldDef = $view->table->fields->first(fn($f) => $f->id === $cleanId); + @endphp + @if ($fieldDef && $fieldDef->type !== 'title') + @php + $fieldVal = null; + foreach ($rec as $k => $v) { + if (strcasecmp($k, $fieldDef->name) === 0) { + $fieldVal = $v; + break; + } + } + @endphp +
+
{{ $fieldDef->name }}
+
+ @if (is_array($fieldVal)) + {{ implode(', ', $fieldVal) }} + @else + {{ $fieldVal ?? '-' }} + @endif +
+
+ @endif + @endforeach +
+
+
+ @endforeach + @else + + + + @foreach ($columns as $col) + + @endforeach + + + + @if (empty($group['records'])) + + + + @else + @foreach ($group['records'] as $rec) + + @foreach ($columns as $col) + + @endforeach + + @endforeach + @endif + +
{{ $col }}
Aucune fiche
+ @if (is_array($rec[$col] ?? null)) + {{ implode(', ', $rec[$col]) }} + @else + {{ $rec[$col] ?? '-' }} + @endif +
+ @endif + @endif +
+ @endforeach + + diff --git a/apps/api/routes/api.php b/apps/api/routes/api.php index 9eea41f..ad9640c 100644 --- a/apps/api/routes/api.php +++ b/apps/api/routes/api.php @@ -6,6 +6,8 @@ use App\Http\Controllers\DatabaseMapPointController; use App\Http\Controllers\FieldController; use App\Http\Controllers\RecordController; +use App\Http\Controllers\ReportController; +use App\Http\Controllers\ShareController; use App\Http\Controllers\TableController; use App\Http\Controllers\TemplateController; use App\Http\Controllers\UploadController; @@ -28,10 +30,22 @@ Route::apiResource('views', ViewController::class); + // Report routes + Route::post('reports/preview', [ReportController::class, 'preview']); + Route::post('reports/preview/pdf', [ReportController::class, 'previewPdf']); + Route::post('reports/preview/csv', [ReportController::class, 'previewCsv']); + Route::get('reports/{report}/export/pdf', [ReportController::class, 'exportPdf']); + Route::get('reports/{report}/export/csv', [ReportController::class, 'exportCsv']); + Route::match(['get', 'post'], 'reports/{report}/execute', [ReportController::class, 'execute']); + Route::apiResource('reports', ReportController::class); + // Upload creation route Route::post('uploads', [UploadController::class, 'store']); Route::apiResource('databases', DatabaseController::class); + Route::get('databases/{database}/shares', [ShareController::class, 'index']); + Route::post('databases/{database}/shares', [ShareController::class, 'store']); + Route::delete('shares/{share}', [ShareController::class, 'destroy']); Route::get('databases/{database}/map-points', [DatabaseMapPointController::class, 'index']); Route::get('templates', [TemplateController::class, 'index']); Route::post('databases/{database}/export-template', [TemplateController::class, 'export']); @@ -69,4 +83,10 @@ Route::get('uploads/{hash}', [UploadController::class, 'show']); Route::get('uploads/{hash}/thumbnail', [UploadController::class, 'showThumbnail']); + + Route::middleware('throttle:60,1')->group(function () { + Route::get('shares/{token}', [ShareController::class, 'show']); + Route::get('shares/{token}/uploads/{hash}', [ShareController::class, 'serveUpload']); + Route::get('shares/{token}/uploads/{hash}/thumbnail', [ShareController::class, 'serveUploadThumbnail']); + }); }); diff --git a/apps/api/tests/Feature/ReportFeatureTest.php b/apps/api/tests/Feature/ReportFeatureTest.php new file mode 100644 index 0000000..9edd274 --- /dev/null +++ b/apps/api/tests/Feature/ReportFeatureTest.php @@ -0,0 +1,361 @@ +create(); + $workspace = Workspace::factory()->create(); + WorkspaceMember::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + 'role' => 'owner', + ]); + + $database = Database::factory()->create(['workspace_id' => $workspace->id]); + $table = Table::factory()->create(['database_id' => $database->id]); + + return [ + 'user' => $user, + 'workspace' => $workspace, + 'database' => $database, + 'table' => $table, + ]; +} + +test('report CRUD operations', function () { + $setup = createAuthenticatedUser(); + $user = $setup['user']; + $table = $setup['table']; + + // Create Report + $payload = [ + 'table_id' => $table->id, + 'name' => 'Monthly sales', + 'query' => [ + 'select' => ['title', 'price'], + 'where' => [ + 'logic' => 'and', + 'conditions' => [ + ['field' => 'price', 'operator' => 'gt', 'value' => 10], + ], + ], + ], + 'layout' => [ + 'fields' => [ + ['name' => 'title', 'visible' => true, 'order' => 1], + ['name' => 'price', 'visible' => true, 'order' => 2], + ], + 'group_order' => [], + ], + ]; + + $response = $this->actingAs($user) + ->postJson('/api/v1/reports', $payload); + + $response->assertStatus(201) + ->assertJsonPath('name', 'Monthly sales') + ->assertJsonPath('query.select', ['title', 'price']); + + $reportId = $response->json('id'); + + // List Reports + $listResponse = $this->actingAs($user) + ->getJson('/api/v1/reports?database_id='.$setup['database']->id); + + $listResponse->assertStatus(200) + ->assertJsonCount(1); + + // Show Report + $showResponse = $this->actingAs($user) + ->getJson('/api/v1/reports/'.$reportId); + + $showResponse->assertStatus(200) + ->assertJsonPath('name', 'Monthly sales'); + + // Update Report + $updatePayload = [ + 'name' => 'Updated monthly sales', + 'layout' => [ + 'fields' => [ + ['name' => 'title', 'visible' => true, 'order' => 2], + ['name' => 'price', 'visible' => false, 'order' => 1], + ], + ], + ]; + + $updateResponse = $this->actingAs($user) + ->putJson('/api/v1/reports/'.$reportId, $updatePayload); + + $updateResponse->assertStatus(200) + ->assertJsonPath('name', 'Updated monthly sales') + ->assertJsonPath('layout.fields.1.visible', false); + + // Destroy Report + $deleteResponse = $this->actingAs($user) + ->deleteJson('/api/v1/reports/'.$reportId); + + $deleteResponse->assertStatus(204); + + $this->assertDatabaseMissing('reports', ['id' => $reportId]); +}); + +test('paginated preview of report query with layout constraints', function () { + $setup = createAuthenticatedUser(); + $user = $setup['user']; + $table = $setup['table']; + + // Setup fields + $titleField = Field::factory()->create([ + 'table_id' => $table->id, + 'name' => 'title', + 'type' => 'text', + ]); + $priceField = Field::factory()->create([ + 'table_id' => $table->id, + 'name' => 'price', + 'type' => 'number', + ]); + $genreField = Field::factory()->create([ + 'table_id' => $table->id, + 'name' => 'genre', + 'type' => 'text', + ]); + + // Create 5 records + for ($i = 1; $i <= 5; $i++) { + Record::factory()->create([ + 'table_id' => $table->id, + 'data' => [ + 'title' => 'Book '.$i, + 'price' => $i * 10, + 'genre' => $i % 2 === 0 ? 'Sci-Fi' : 'Fantasy', + ], + ]); + } + + $previewPayload = [ + 'table_id' => $table->id, + 'query' => [ + 'select' => ['title', 'price', 'genre'], + 'group_by' => 'genre', + 'sort' => [ + ['field' => 'price', 'direction' => 'desc'], + ], + ], + 'layout' => [ + 'fields' => [ + ['name' => 'title', 'visible' => true, 'order' => 1], + ['name' => 'price', 'visible' => true, 'order' => 2], + ['name' => 'genre', 'visible' => false, 'order' => 3], // genre hidden in layout + ], + 'group_order' => ['Sci-Fi', 'Fantasy'], // Custom group ordering + ], + 'per_page' => 3, + 'page' => 1, + ]; + + $response = $this->actingAs($user) + ->postJson('/api/v1/reports/preview', $previewPayload); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'columns', + 'groups' => [ + '*' => [ + 'key', + 'records', + ], + ], + 'pagination' => [ + 'current_page', + 'per_page', + 'total', + 'last_page', + ], + ]); + + // Paginated check (3 items out of 5 total) + expect($response->json('pagination.total'))->toBe(5); + expect($response->json('pagination.current_page'))->toBe(1); + expect($response->json('pagination.per_page'))->toBe(3); + + // Layout check: genre should be excluded from columns and records + expect($response->json('columns'))->toBe(['title', 'price']); + $groups = $response->json('groups'); + foreach ($groups as $group) { + foreach ($group['records'] as $rec) { + expect($rec)->not->toHaveKey('genre'); + expect($rec)->toHaveKey('title'); + expect($rec)->toHaveKey('price'); + } + } + + // Group order check: Sci-Fi first, Fantasy second + expect($groups[0]['key'])->toBe('Sci-Fi'); + expect($groups[1]['key'])->toBe('Fantasy'); +}); + +test('saved report execution with override pagination', function () { + $setup = createAuthenticatedUser(); + $user = $setup['user']; + $table = $setup['table']; + + // Setup fields + $titleField = Field::factory()->create([ + 'table_id' => $table->id, + 'name' => 'title', + 'type' => 'text', + ]); + $priceField = Field::factory()->create([ + 'table_id' => $table->id, + 'name' => 'price', + 'type' => 'number', + ]); + + // Create 3 records + for ($i = 1; $i <= 3; $i++) { + Record::factory()->create([ + 'table_id' => $table->id, + 'data' => [ + 'title' => 'Product '.$i, + 'price' => $i * 100, + ], + ]); + } + + // Create Report + $report = Report::create([ + 'table_id' => $table->id, + 'name' => 'Expensive products', + 'query' => [ + 'select' => ['title', 'price'], + 'where' => [ + 'logic' => 'and', + 'conditions' => [ + ['field' => 'price', 'operator' => 'gt', 'value' => 150], + ], + ], + ], + 'layout' => [ + 'fields' => [ + ['name' => 'title', 'visible' => true, 'order' => 1], + ['name' => 'price', 'visible' => true, 'order' => 2], + ], + ], + ]); + + // Execute with no pagination + $response = $this->actingAs($user) + ->postJson("/api/v1/reports/{$report->id}/execute"); + + $response->assertStatus(200) + ->assertJsonMissing(['pagination']); + + $records = collect($response->json('groups'))->flatMap(fn ($g) => $g['records']); + expect($records)->toHaveCount(2); // Product 2 (200), Product 3 (300) + + // Execute with override pagination (1 per page, page 2) + $responseOverride = $this->actingAs($user) + ->postJson("/api/v1/reports/{$report->id}/execute", [ + 'per_page' => 1, + 'page' => 2, + ]); + + $responseOverride->assertStatus(200) + ->assertJsonStructure(['pagination']); + + expect($responseOverride->json('pagination.total'))->toBe(2); + expect($responseOverride->json('pagination.current_page'))->toBe(2); + expect($responseOverride->json('pagination.last_page'))->toBe(2); + + $recordsOverride = collect($responseOverride->json('groups'))->flatMap(fn ($g) => $g['records']); + expect($recordsOverride)->toHaveCount(1); +}); + +test('export saved report and preview as PDF and CSV formats', function () { + $setup = createAuthenticatedUser(); + $user = $setup['user']; + $table = $setup['table']; + + // Setup fields + $titleField = Field::factory()->create([ + 'table_id' => $table->id, + 'name' => 'title', + 'type' => 'text', + ]); + $priceField = Field::factory()->create([ + 'table_id' => $table->id, + 'name' => 'price', + 'type' => 'number', + ]); + + // Create a record + Record::factory()->create([ + 'table_id' => $table->id, + 'data' => [ + 'title' => 'Sample item', + 'price' => 99, + ], + ]); + + // Create saved Report + $report = Report::create([ + 'table_id' => $table->id, + 'name' => 'Sample report', + 'query' => [ + 'select' => ['title', 'price'], + ], + 'layout' => [ + 'fields' => [ + ['name' => 'title', 'visible' => true, 'order' => 1], + ['name' => 'price', 'visible' => true, 'order' => 2], + ], + ], + ]); + + // 1. Test saved PDF export + $pdfResponse = $this->actingAs($user) + ->get("/api/v1/reports/{$report->id}/export/pdf"); + $pdfResponse->assertStatus(200); + expect($pdfResponse->headers->get('Content-Type'))->toBe('application/pdf'); + expect(str_contains($pdfResponse->getContent(), '%PDF'))->toBeTrue(); + + // 2. Test saved CSV export + $csvResponse = $this->actingAs($user) + ->get("/api/v1/reports/{$report->id}/export/csv"); + $csvResponse->assertStatus(200); + expect($csvResponse->headers->get('Content-Type'))->toContain('text/csv'); + expect(str_contains($csvResponse->streamedContent(), 'Sample item'))->toBeTrue(); + + // 3. Test on-the-fly preview PDF export + $previewPdfResponse = $this->actingAs($user) + ->postJson('/api/v1/reports/preview/pdf', [ + 'table_id' => $table->id, + 'name' => 'On the fly PDF', + 'query' => ['select' => ['title']], + ]); + $previewPdfResponse->assertStatus(200); + expect($previewPdfResponse->headers->get('Content-Type'))->toBe('application/pdf'); + expect(str_contains($previewPdfResponse->getContent(), '%PDF'))->toBeTrue(); + + // 4. Test on-the-fly preview CSV export + $previewCsvResponse = $this->actingAs($user) + ->postJson('/api/v1/reports/preview/csv', [ + 'table_id' => $table->id, + 'query' => ['select' => ['title']], + ]); + $previewCsvResponse->assertStatus(200); + expect($previewCsvResponse->headers->get('Content-Type'))->toContain('text/csv'); + expect(str_contains($previewCsvResponse->streamedContent(), 'title'))->toBeTrue(); +}); diff --git a/apps/api/tests/Feature/ReportQueryServiceTest.php b/apps/api/tests/Feature/ReportQueryServiceTest.php new file mode 100644 index 0000000..ccbde0f --- /dev/null +++ b/apps/api/tests/Feature/ReportQueryServiceTest.php @@ -0,0 +1,367 @@ +create(); + $database = Database::factory()->create(['workspace_id' => $workspace->id]); + $authorsTable = Table::factory()->create([ + 'database_id' => $database->id, + 'name' => 'authors', + ]); + $booksTable = Table::factory()->create([ + 'database_id' => $database->id, + 'name' => 'books', + ]); + + $authorNameField = Field::factory()->create([ + 'table_id' => $authorsTable->id, + 'name' => 'name', + 'type' => 'text', + ]); + $authorBirthField = Field::factory()->create([ + 'table_id' => $authorsTable->id, + 'name' => 'birth_year', + 'type' => 'number', + ]); + + $bookTitleField = Field::factory()->create([ + 'table_id' => $booksTable->id, + 'name' => 'title', + 'type' => 'text', + ]); + $bookPriceField = Field::factory()->create([ + 'table_id' => $booksTable->id, + 'name' => 'price', + 'type' => 'number', + ]); + $bookGenreField = Field::factory()->create([ + 'table_id' => $booksTable->id, + 'name' => 'genre', + 'type' => 'text', + ]); + $bookAuthorField = Field::factory()->create([ + 'table_id' => $booksTable->id, + 'name' => 'author', + 'type' => 'reference', + 'options' => ['multi' => false, 'target_table' => $authorsTable->id], + ]); + $bookCoAuthorsField = Field::factory()->create([ + 'table_id' => $booksTable->id, + 'name' => 'co_authors', + 'type' => 'reference', + 'options' => ['multi' => true, 'target_table' => $authorsTable->id], + ]); + + // Create Authors + $rowling = Record::factory()->create([ + 'table_id' => $authorsTable->id, + 'data' => ['name' => 'J.K. Rowling', 'birth_year' => 1965], + ]); + $tolkien = Record::factory()->create([ + 'table_id' => $authorsTable->id, + 'data' => ['name' => 'J.R.R. Tolkien', 'birth_year' => 1892], + ]); + $sanderson = Record::factory()->create([ + 'table_id' => $authorsTable->id, + 'data' => ['name' => 'Brandon Sanderson', 'birth_year' => 1975], + ]); + + // Create Books + $hp1 = Record::factory()->create([ + 'table_id' => $booksTable->id, + 'data' => [ + 'title' => 'Harry Potter 1', + 'price' => 15, + 'genre' => 'Fantasy', + 'author' => $rowling->id, + 'co_authors' => [], + ], + ]); + $lotr = Record::factory()->create([ + 'table_id' => $booksTable->id, + 'data' => [ + 'title' => 'Lord of the Rings', + 'price' => 25, + 'genre' => 'Fantasy', + 'author' => $tolkien->id, + 'co_authors' => [$sanderson->id], + ], + ]); + $twok = Record::factory()->create([ + 'table_id' => $booksTable->id, + 'data' => [ + 'title' => 'The Way of Kings', + 'price' => 30, + 'genre' => 'Fantasy', + 'author' => $sanderson->id, + 'co_authors' => [$rowling->id, $tolkien->id], + ], + ]); + $nonfiction1 = Record::factory()->create([ + 'table_id' => $booksTable->id, + 'data' => [ + 'title' => 'Some History Book', + 'price' => 45, + 'genre' => 'History', + 'author' => null, + 'co_authors' => [], + ], + ]); + + // Create Record Links + RecordLink::create([ + 'from_record' => $hp1->id, + 'field_id' => $bookAuthorField->id, + 'to_record' => $rowling->id, + ]); + + RecordLink::create([ + 'from_record' => $lotr->id, + 'field_id' => $bookAuthorField->id, + 'to_record' => $tolkien->id, + ]); + RecordLink::create([ + 'from_record' => $lotr->id, + 'field_id' => $bookCoAuthorsField->id, + 'to_record' => $sanderson->id, + ]); + + RecordLink::create([ + 'from_record' => $twok->id, + 'field_id' => $bookAuthorField->id, + 'to_record' => $sanderson->id, + ]); + RecordLink::create([ + 'from_record' => $twok->id, + 'field_id' => $bookCoAuthorsField->id, + 'to_record' => $rowling->id, + ]); + RecordLink::create([ + 'from_record' => $twok->id, + 'field_id' => $bookCoAuthorsField->id, + 'to_record' => $tolkien->id, + ]); + + return [ + 'booksTable' => $booksTable, + 'hp1' => $hp1, + 'lotr' => $lotr, + 'twok' => $twok, + 'nonfiction1' => $nonfiction1, + 'rowling' => $rowling, + 'tolkien' => $tolkien, + 'sanderson' => $sanderson, + ]; +} + +test('resolves scalar and reference projections correctly', function () { + $data = setupTestData(); + $service = new ReportQueryService; + + $ast = [ + 'select' => ['title', 'price', 'author.name', 'author.birth_year', 'author.id', 'co_authors.name'], + ]; + + $result = $service->execute($data['booksTable'], $ast); + + expect($result['columns'])->toBe($ast['select']); + + $records = collect($result['groups'])->flatMap(fn ($group) => $group['records']); + + // Check HP1 projection + $hp1Row = $records->firstWhere('id', $data['hp1']->id); + expect($hp1Row['title'])->toBe('Harry Potter 1'); + expect($hp1Row['price'])->toBe(15); + expect($hp1Row['author.name'])->toBe('J.K. Rowling'); + expect($hp1Row['author.birth_year'])->toBe(1965); + expect($hp1Row['author.id'])->toBe($data['rowling']->id); + expect($hp1Row['co_authors.name'])->toBeEmpty(); + + // Check LOTR projection + $lotrRow = $records->firstWhere('id', $data['lotr']->id); + expect($lotrRow['title'])->toBe('Lord of the Rings'); + expect($lotrRow['author.name'])->toBe('J.R.R. Tolkien'); + expect($lotrRow['co_authors.name'])->toBe(['Brandon Sanderson']); + + // Check TWOK projection + $twokRow = $records->firstWhere('id', $data['twok']->id); + expect($twokRow['title'])->toBe('The Way of Kings'); + expect($twokRow['author.name'])->toBe('Brandon Sanderson'); + // co_authors.name contains Rowling and Tolkien (order can depend but both should be there) + expect($twokRow['co_authors.name'])->toContain('J.K. Rowling'); + expect($twokRow['co_authors.name'])->toContain('J.R.R. Tolkien'); + + // Check Null Author book projection + $nfRow = $records->firstWhere('id', $data['nonfiction1']->id); + expect($nfRow['author.name'])->toBeNull(); + expect($nfRow['co_authors.name'])->toBeEmpty(); +}); + +test('applies scalar filter operators correctly', function () { + $data = setupTestData(); + $service = new ReportQueryService; + + // Test 'gt' operator + $ast = [ + 'select' => ['title', 'price'], + 'where' => [ + 'logic' => 'and', + 'conditions' => [ + ['field' => 'price', 'operator' => 'gt', 'value' => 20], + ], + ], + ]; + + $result = $service->execute($data['booksTable'], $ast); + $records = collect($result['groups'])->flatMap(fn ($group) => $group['records']); + expect($records)->toHaveCount(3); // LOTR (25), TWOK (30), NonFiction (45) + expect($records->pluck('title'))->not->toContain('Harry Potter 1'); + + // Test 'eq' and nested 'or' operator + $ast = [ + 'select' => ['title'], + 'where' => [ + 'logic' => 'or', + 'conditions' => [ + ['field' => 'title', 'operator' => 'eq', 'value' => 'Harry Potter 1'], + ['field' => 'price', 'operator' => 'gte', 'value' => 40], + ], + ], + ]; + + $result = $service->execute($data['booksTable'], $ast); + $records = collect($result['groups'])->flatMap(fn ($group) => $group['records']); + expect($records)->toHaveCount(2); // HP1, NonFiction + expect($records->pluck('title'))->toContain('Harry Potter 1'); + expect($records->pluck('title'))->toContain('Some History Book'); + + // Test 'contains' and 'neq' + $ast = [ + 'select' => ['title'], + 'where' => [ + 'logic' => 'and', + 'conditions' => [ + ['field' => 'title', 'operator' => 'contains', 'value' => 'King'], + ['field' => 'price', 'operator' => 'neq', 'value' => 10], + ], + ], + ]; + + $result = $service->execute($data['booksTable'], $ast); + $records = collect($result['groups'])->flatMap(fn ($group) => $group['records']); + expect($records)->toHaveCount(1); + expect($records->first()['title'])->toBe('The Way of Kings'); + + // Test 'is_null' + $ast = [ + 'select' => ['title'], + 'where' => [ + 'logic' => 'and', + 'conditions' => [ + ['field' => 'author', 'operator' => 'is_null', 'value' => null], + ], + ], + ]; + + $result = $service->execute($data['booksTable'], $ast); + $records = collect($result['groups'])->flatMap(fn ($group) => $group['records']); + expect($records)->toHaveCount(1); + expect($records->first()['title'])->toBe('Some History Book'); +}); + +test('applies reference filters correctly', function () { + $data = setupTestData(); + $service = new ReportQueryService; + + // Filter books where author.name is Brandon Sanderson + $ast = [ + 'select' => ['title', 'author.name'], + 'where' => [ + 'logic' => 'and', + 'conditions' => [ + ['field' => 'author.name', 'operator' => 'eq', 'value' => 'Brandon Sanderson'], + ], + ], + ]; + + $result = $service->execute($data['booksTable'], $ast); + $records = collect($result['groups'])->flatMap(fn ($group) => $group['records']); + expect($records)->toHaveCount(1); + expect($records->first()['title'])->toBe('The Way of Kings'); +}); + +test('applies sorting correctly', function () { + $data = setupTestData(); + $service = new ReportQueryService; + + // Sort by price desc + $ast = [ + 'select' => ['title', 'price'], + 'sort' => [ + ['field' => 'price', 'direction' => 'desc'], + ], + ]; + + $result = $service->execute($data['booksTable'], $ast); + $records = collect($result['groups'])->flatMap(fn ($group) => $group['records']); + $titles = $records->pluck('title')->toArray(); + + expect($titles)->toBe([ + 'Some History Book', // 45 + 'The Way of Kings', // 30 + 'Lord of the Rings', // 25 + 'Harry Potter 1', // 15 + ]); +}); + +test('applies grouping correctly', function () { + $data = setupTestData(); + $service = new ReportQueryService; + + // Group by genre (scalar) + $ast = [ + 'select' => ['title', 'genre'], + 'group_by' => 'genre', + ]; + + $result = $service->execute($data['booksTable'], $ast); + + expect($result['groups'])->toHaveCount(2); // Fantasy, History + + $fantasyGroup = collect($result['groups'])->firstWhere('key', 'Fantasy'); + $historyGroup = collect($result['groups'])->firstWhere('key', 'History'); + + expect($fantasyGroup)->not->toBeNull(); + expect($historyGroup)->not->toBeNull(); + expect($fantasyGroup['records'])->toHaveCount(3); + expect($historyGroup['records'])->toHaveCount(1); + + // Group by author.name (reference traversal) + $ast2 = [ + 'select' => ['title', 'author.name'], + 'group_by' => 'author.name', + ]; + + $result2 = $service->execute($data['booksTable'], $ast2); + // Groups should be: J.K. Rowling, J.R.R. Tolkien, Brandon Sanderson, Inconnu + expect($result2['groups'])->toHaveCount(4); + + $rowlingGroup = collect($result2['groups'])->firstWhere('key', 'J.K. Rowling'); + $inconnuGroup = collect($result2['groups'])->firstWhere('key', 'Inconnu'); + + expect($rowlingGroup)->not->toBeNull(); + expect($inconnuGroup)->not->toBeNull(); + expect($rowlingGroup['records'][0]['title'])->toBe('Harry Potter 1'); + expect($inconnuGroup['records'][0]['title'])->toBe('Some History Book'); +}); diff --git a/apps/api/tests/Feature/ShareFeatureTest.php b/apps/api/tests/Feature/ShareFeatureTest.php new file mode 100644 index 0000000..804b2ec --- /dev/null +++ b/apps/api/tests/Feature/ShareFeatureTest.php @@ -0,0 +1,190 @@ +user = User::factory()->create(); + $this->workspace = Workspace::create(['name' => 'Test Workspace', 'created_by' => $this->user->id]); + + $member = new WorkspaceMember; + $member->workspace_id = $this->workspace->id; + $member->user_id = $this->user->id; + $member->role = 'owner'; + $member->save(); + + $this->database = Database::create([ + 'workspace_id' => $this->workspace->id, + 'name' => 'Archives Patrimoine', + 'locale' => 'fr_FR', + ]); + + $this->table = Table::create([ + 'database_id' => $this->database->id, + 'name' => 'Ouvrages', + ]); + + $this->titleField = Field::create([ + 'table_id' => $this->table->id, + 'name' => 'Titre', + 'type' => 'title', + 'options' => ['is_title' => true], + ]); + + $this->imageField = Field::create([ + 'table_id' => $this->table->id, + 'name' => 'Illustrations', + 'type' => 'image', + ]); + + $this->record = Record::create([ + 'table_id' => $this->table->id, + 'data' => [ + 'Titre' => 'Le Rouge et le Noir', + 'Illustrations' => [['hash' => 'dummyhash123', 'name' => 'cover.jpg']], + ], + ]); + + $this->view = View::create([ + 'table_id' => $this->table->id, + 'name' => 'Vue Publique', + 'type' => 'card', + 'config' => ['columns' => [[$this->titleField->id]]], + ]); + + $this->report = Report::create([ + 'table_id' => $this->table->id, + 'name' => 'Rapport Annuel', + 'query' => ['select' => ['Titre']], + 'layout' => ['show_headers_only' => false], + ]); +}); + +test('authenticated user can create and list shares', function () { + $response = $this->actingAs($this->user) + ->postJson("/api/v1/databases/{$this->database->id}/shares", [ + 'name' => 'Partage Ouvrage', + 'target_type' => 'record', + 'target_id' => $this->record->id, + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('shares', [ + 'name' => 'Partage Ouvrage', + 'target_type' => 'record', + 'target_id' => $this->record->id, + ]); + + $token = $response->json('token'); + + // List shares + $listResponse = $this->actingAs($this->user) + ->getJson("/api/v1/databases/{$this->database->id}/shares"); + + $listResponse->assertStatus(200); + $listResponse->assertJsonFragment([ + 'name' => 'Partage Ouvrage', + 'token' => $token, + 'target_type' => 'record', + ]); +}); + +test('public user can retrieve record details via token', function () { + $share = Share::create([ + 'database_id' => $this->database->id, + 'name' => 'Partage Fiche', + 'token' => 'unguesstoken123', + 'target_type' => 'record', + 'target_id' => $this->record->id, + ]); + + $response = $this->getJson("/api/v1/shares/{$share->token}"); + + $response->assertStatus(200); + $response->assertJson([ + 'target_type' => 'record', + 'name' => 'Partage Fiche', + 'record' => [ + 'id' => $this->record->id, + 'data' => [ + 'Titre' => 'Le Rouge et le Noir', + ], + ], + ]); +}); + +test('expired share token returns 410', function () { + $share = Share::create([ + 'database_id' => $this->database->id, + 'name' => 'Partage Expire', + 'token' => 'expiredtoken', + 'target_type' => 'record', + 'target_id' => $this->record->id, + 'expires_at' => now()->subDay(), + ]); + + $response = $this->getJson("/api/v1/shares/{$share->token}"); + $response->assertStatus(410); +}); + +test('revoking a share link deletes it', function () { + $share = Share::create([ + 'database_id' => $this->database->id, + 'name' => 'Partage Revocable', + 'token' => 'revoketoken', + 'target_type' => 'record', + 'target_id' => $this->record->id, + ]); + + $response = $this->actingAs($this->user) + ->deleteJson("/api/v1/shares/{$share->id}"); + + $response->assertStatus(204); + $this->assertDatabaseMissing('shares', ['id' => $share->id]); +}); + +test('serving uploads strips EXIF metadata from original JPEGs', function () { + $share = Share::create([ + 'database_id' => $this->database->id, + 'name' => 'Partage Image', + 'token' => 'imagetoken', + 'target_type' => 'record', + 'target_id' => $this->record->id, + ]); + + $file = UploadedFile::fake()->image('cover.jpg', 300, 200); + $hash = hash_file('sha256', $file->getRealPath()); + + // Update record to reference the actual upload hash + $this->record->update([ + 'data' => array_merge($this->record->data, [ + 'Illustrations' => [['hash' => $hash, 'name' => 'cover.jpg']], + ]), + ]); + + // Put to fake storage + Storage::put("uploads/{$hash}", file_get_contents($file->getRealPath())); + + // Verify it is served securely through shares route + $response = $this->get("/api/v1/shares/{$share->token}/uploads/{$hash}"); + $response->assertStatus(200); + + // Verify requesting an unauthorized hash returns 403 + $wrongResponse = $this->get("/api/v1/shares/{$share->token}/uploads/wronghash"); + $wrongResponse->assertStatus(403); +}); diff --git a/apps/client/package.json b/apps/client/package.json index cb847d7..fe2dd0d 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -19,6 +19,10 @@ "@tanstack/react-router": "^1.170.17", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.14.5", + "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-fs": "^2.5.1", + "@tauri-apps/plugin-sql": "^2.4.0", "date-fns": "^4.4.0", "i18next": "^26.3.4", "maplibre-gl": "^5.24.0", diff --git a/apps/client/src/components/FieldOptionPanel.tsx b/apps/client/src/components/FieldOptionPanel.tsx index 9d79158..0c73b29 100644 --- a/apps/client/src/components/FieldOptionPanel.tsx +++ b/apps/client/src/components/FieldOptionPanel.tsx @@ -56,6 +56,7 @@ function InputControl({ currentField?: BuilderField; }) { const { t } = useI18n(); + const inputRef = useRef(null); switch (schema.type) { case 'boolean': @@ -162,8 +163,6 @@ function InputControl({ case 'string': default: { - const inputRef = useRef(null); - const insertTag = (fieldName: string) => { const input = inputRef.current; if (!input) return; diff --git a/apps/client/src/components/ShareModal.tsx b/apps/client/src/components/ShareModal.tsx new file mode 100644 index 0000000..bbd8b97 --- /dev/null +++ b/apps/client/src/components/ShareModal.tsx @@ -0,0 +1,200 @@ +import { useState, useEffect } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { apiClient } from '../lib/apiClient'; + +interface ShareModalProps { + databaseId: string; + targetType: 'record' | 'view' | 'report'; + targetId: string; + targetName: string; + isOpen: boolean; + onClose: () => void; +} + +export function ShareModal({ + databaseId, + targetType, + targetId, + targetName, + isOpen, + onClose, +}: ShareModalProps) { + const [shareName, setShareName] = useState(''); + const [expiryOption, setExpiryOption] = useState<'24h' | '7d' | '30d' | 'never'>('never'); + const [shareLink, setShareLink] = useState(''); + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (isOpen) { + setShareName(targetName); + setShareLink(''); + setCopied(false); + setExpiryOption('never'); + } + }, [isOpen, targetName]); + + const generateShareMutation = useMutation({ + mutationFn: async () => { + let expiresAt: string | null = null; + if (expiryOption === '24h') { + expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + } else if (expiryOption === '7d') { + expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + } else if (expiryOption === '30d') { + expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); + } + + return apiClient.post(`/databases/${databaseId}/shares`, { + name: shareName || `Partage ${targetType}`, + target_type: targetType, + target_id: targetId, + expires_at: expiresAt, + }); + }, + onSuccess: (data: any) => { + const publicLink = `${window.location.origin}/public-shares/${data.token}`; + setShareLink(publicLink); + }, + }); + + if (!isOpen) return null; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(shareLink); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error(err); + } + }; + + const handleMailTo = () => { + const subject = encodeURIComponent(`Lien de partage : ${shareName}`); + const body = encodeURIComponent( + `Bonjour,\n\nVoici le lien de partage pour consulter cet élément en lecture seule :\n${shareLink}\n\nCordialement.` + ); + window.location.href = `mailto:?subject=${subject}&body=${body}`; + }; + + return ( + <> +
+
+
+
+
+
+ + Partager cet élément +
+
+
+ {!shareLink ? ( +
+
+ Créez un lien de partage sécurisé en lecture seule pour cet élément ( + {targetName}). +
+
+ + setShareName(e.target.value)} + placeholder="Nom descriptif" + /> +
+
+ + +
+
+ + +
+
+ ) : ( +
+
+ + Lien de partage généré avec succès ! +
+
+ +
+ + +
+
+
+ + +
+
+ )} +
+
+
+
+ + ); +} diff --git a/apps/client/src/contexts/AuthContext.tsx b/apps/client/src/contexts/AuthContext.tsx index 3e7432d..685f0a7 100644 --- a/apps/client/src/contexts/AuthContext.tsx +++ b/apps/client/src/contexts/AuthContext.tsx @@ -65,6 +65,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { }; useEffect(() => { + if (window.location.pathname.startsWith('/public-shares/')) { + setIsLoading(false); + return; + } + checkAuth(); }, []); diff --git a/apps/client/src/contexts/I18nContext.tsx b/apps/client/src/contexts/I18nContext.tsx index a6fc178..e5a5c41 100644 --- a/apps/client/src/contexts/I18nContext.tsx +++ b/apps/client/src/contexts/I18nContext.tsx @@ -104,6 +104,30 @@ const resources = { 'layout.column.empty': 'Déposez des champs ici.', 'layout.saveLayout': 'Enregistrer la disposition', 'layout.saveSuccess': 'Disposition enregistrée avec succès !', + 'reports.title': 'Générateur de rapports', + 'reports.subtitle.database': 'Base de données', + 'reports.subtitle.table': 'Table', + 'reports.newReport': 'Nouveau rapport', + 'reports.savedReports': 'Rapports enregistrés', + 'reports.name.label': 'Nom du rapport', + 'reports.columns.label': 'Colonnes affichées', + 'reports.groupBy.label': 'Grouper par', + 'reports.showHeadersOnly.label': 'Afficher uniquement les en-têtes', + 'reports.sortBy.label': 'Trier par', + 'reports.conditions.label': 'Conditions de filtrage', + 'reports.conditions.logic.and': 'Toutes les conditions (ET)', + 'reports.conditions.logic.or': 'Au moins une condition (OU)', + 'reports.addCondition': 'Ajouter une condition', + 'reports.addSort': 'Ajouter un tri', + 'reports.preview.live': 'Prévisualisation en direct', + 'reports.preview.print': 'Rendu imprimé', + 'reports.print': 'Imprimer', + 'reports.save': 'Enregistrer le rapport', + 'reports.delete': 'Supprimer le rapport', + 'reports.noReports': 'Aucun rapport enregistré pour le moment.', + 'reports.confirmDelete': 'Êtes-vous sûr de vouloir supprimer ce rapport ?', + 'reports.emptyPreview': 'Configurez des colonnes pour prévisualiser le rapport.', + 'reports.loading': 'Chargement des données du rapport...', 'builder.title': 'Éditeur de structure', 'builder.subtitle.database': 'Base de données', 'builder.subtitle.table': 'Table', @@ -179,6 +203,7 @@ const resources = { 'workspaces.tables.count': 'table(s)', 'workspaces.tables.empty': 'Aucune table', 'workspaces.builder.action': 'Éditeur de structure', + 'workspaces.reports.action': 'Rapports', 'workspaces.table.isFrontFacing': 'De façade', 'builder.isFrontFacing': 'Table de façade', 'nav.navigation': 'Navigation', @@ -375,6 +400,30 @@ const resources = { 'layout.column.empty': 'Drop fields here.', 'layout.saveLayout': 'Save Layout', 'layout.saveSuccess': 'Layout saved successfully!', + 'reports.title': 'Report Builder', + 'reports.subtitle.database': 'Database', + 'reports.subtitle.table': 'Table', + 'reports.newReport': 'New Report', + 'reports.savedReports': 'Saved Reports', + 'reports.name.label': 'Report Name', + 'reports.columns.label': 'Displayed Columns', + 'reports.groupBy.label': 'Group By', + 'reports.showHeadersOnly.label': 'Show headers only', + 'reports.sortBy.label': 'Sort By', + 'reports.conditions.label': 'Filter Conditions', + 'reports.conditions.logic.and': 'All conditions (AND)', + 'reports.conditions.logic.or': 'Any condition (OR)', + 'reports.addCondition': 'Add Condition', + 'reports.addSort': 'Add Sort', + 'reports.preview.live': 'Live Preview', + 'reports.preview.print': 'Print Layout', + 'reports.print': 'Print', + 'reports.save': 'Save Report', + 'reports.delete': 'Delete Report', + 'reports.noReports': 'No saved reports yet.', + 'reports.confirmDelete': 'Are you sure you want to delete this report?', + 'reports.emptyPreview': 'Configure columns to preview the report.', + 'reports.loading': 'Loading report data...', 'builder.title': 'Structure Editor', 'builder.subtitle.database': 'Database', 'builder.subtitle.table': 'Table', @@ -448,6 +497,7 @@ const resources = { 'workspaces.tables.count': 'table(s)', 'workspaces.tables.empty': 'No tables', 'workspaces.builder.action': 'Structure editor', + 'workspaces.reports.action': 'Reports', 'workspaces.table.isFrontFacing': 'Front facing', 'builder.isFrontFacing': 'Front facing table', 'nav.navigation': 'Navigation', diff --git a/apps/client/src/lib/__tests__/tauriSpikeProbe.test.ts b/apps/client/src/lib/__tests__/tauriSpikeProbe.test.ts new file mode 100644 index 0000000..8d83e4c --- /dev/null +++ b/apps/client/src/lib/__tests__/tauriSpikeProbe.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + close: vi.fn(), + execute: vi.fn(), + isTauri: vi.fn(), + join: vi.fn(), + load: vi.fn(), + open: vi.fn(), + readTextFile: vi.fn(), + select: vi.fn(), + writeTextFile: vi.fn(), +})); + +vi.mock('@tauri-apps/api/core', () => ({ isTauri: mocks.isTauri })); +vi.mock('@tauri-apps/api/path', () => ({ join: mocks.join })); +vi.mock('@tauri-apps/plugin-dialog', () => ({ open: mocks.open })); +vi.mock('@tauri-apps/plugin-fs', () => ({ + readTextFile: mocks.readTextFile, + writeTextFile: mocks.writeTextFile, +})); +vi.mock('@tauri-apps/plugin-sql', () => ({ default: { load: mocks.load } })); + +import { tauriSpikeProbe } from '../tauriSpikeProbe'; + +describe('tauriSpikeProbe', () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.isTauri.mockReturnValue(true); + mocks.execute.mockResolvedValue({ rowsAffected: 1 }); + mocks.close.mockResolvedValue(true); + mocks.load.mockResolvedValue({ + close: mocks.close, + execute: mocks.execute, + select: mocks.select, + }); + }); + + test('writes and reads the SQLite fixture after reopening the database', async () => { + mocks.select.mockResolvedValue([{ value: 'hap-r3-a-sqlite-persistence' }]); + + await expect(tauriSpikeProbe.verifySqlitePersistence()).resolves.toEqual({ + persisted: true, + value: 'hap-r3-a-sqlite-persistence', + }); + + expect(mocks.load).toHaveBeenCalledTimes(2); + expect(mocks.execute).toHaveBeenCalledWith( + 'INSERT OR REPLACE INTO spike_probe (id, value) VALUES (1, $1)', + ['hap-r3-a-sqlite-persistence'] + ); + expect(mocks.close).toHaveBeenCalledTimes(2); + }); + + test('writes and reads the UTF-8 vault fixture in the selected directory', async () => { + mocks.join.mockResolvedValue('/vault/hap-r3-a-vault-probe.md'); + mocks.readTextFile.mockResolvedValue( + '# HAP R3-A vault probe\n\nCette fiche confirme l’écriture locale UTF-8.\n' + ); + + await expect(tauriSpikeProbe.writeVaultProbe('/vault')).resolves.toEqual({ + contents: '# HAP R3-A vault probe\n\nCette fiche confirme l’écriture locale UTF-8.\n', + path: '/vault/hap-r3-a-vault-probe.md', + }); + + expect(mocks.writeTextFile).toHaveBeenCalledWith( + '/vault/hap-r3-a-vault-probe.md', + '# HAP R3-A vault probe\n\nCette fiche confirme l’écriture locale UTF-8.\n' + ); + }); +}); diff --git a/apps/client/src/lib/tauriSpikeProbe.ts b/apps/client/src/lib/tauriSpikeProbe.ts new file mode 100644 index 0000000..47cbbc4 --- /dev/null +++ b/apps/client/src/lib/tauriSpikeProbe.ts @@ -0,0 +1,65 @@ +import { isTauri } from '@tauri-apps/api/core'; +import { join } from '@tauri-apps/api/path'; +import { open } from '@tauri-apps/plugin-dialog'; +import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'; +import Database from '@tauri-apps/plugin-sql'; +import type { LocalPlatformProbe, SqliteProbeResult, VaultProbeResult } from '@hap/core'; + +const SPIKE_VALUE = 'hap-r3-a-sqlite-persistence'; +const SPIKE_FILE = 'hap-r3-a-vault-probe.md'; + +export const tauriSpikeProbe: LocalPlatformProbe = { + isAvailable: () => isTauri(), + + async verifySqlitePersistence(): Promise { + const database = await Database.load('sqlite:hap-r3-a-spike.sqlite'); + + try { + await database.execute( + 'CREATE TABLE IF NOT EXISTS spike_probe (id INTEGER PRIMARY KEY CHECK (id = 1), value TEXT NOT NULL)' + ); + await database.execute('INSERT OR REPLACE INTO spike_probe (id, value) VALUES (1, $1)', [ + SPIKE_VALUE, + ]); + } finally { + await database.close(); + } + + const reopened = await Database.load('sqlite:hap-r3-a-spike.sqlite'); + try { + const rows = await reopened.select>( + 'SELECT value FROM spike_probe WHERE id = 1' + ); + const value = rows[0]?.value; + if (value !== SPIKE_VALUE) { + throw new Error('SQLite probe value was not persisted after reopening the database.'); + } + + return { value, persisted: true }; + } finally { + await reopened.close(); + } + }, + + async selectVaultDirectory(): Promise { + const result = await open({ + directory: true, + multiple: false, + title: 'Choisir le dossier du vault de test', + }); + return typeof result === 'string' ? result : null; + }, + + async writeVaultProbe(directory: string): Promise { + const path = await join(directory, SPIKE_FILE); + const contents = '# HAP R3-A vault probe\n\nCette fiche confirme l’écriture locale UTF-8.\n'; + await writeTextFile(path, contents); + const persistedContents = await readTextFile(path); + + if (persistedContents !== contents) { + throw new Error('Vault probe contents did not round-trip from the selected directory.'); + } + + return { path, contents: persistedContents }; + }, +}; diff --git a/apps/client/src/routeTree.gen.ts b/apps/client/src/routeTree.gen.ts index f92383c..d24010e 100644 --- a/apps/client/src/routeTree.gen.ts +++ b/apps/client/src/routeTree.gen.ts @@ -8,211 +8,293 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from './routes/__root'; -import { Route as WorkspacesRouteImport } from './routes/workspaces'; -import { Route as PingRouteImport } from './routes/ping'; -import { Route as NotFoundRouteImport } from './routes/not-found'; -import { Route as LoginRouteImport } from './routes/login'; -import { Route as IndexRouteImport } from './routes/index'; -import { Route as NavigationDatabaseIdRouteImport } from './routes/navigation.$databaseId'; -import { Route as TablesDatabaseIdTableIdRouteImport } from './routes/tables.$databaseId.$tableId'; -import { Route as BuilderDatabaseIdTableIdRouteImport } from './routes/builder.$databaseId.$tableId'; -import { Route as NavigationDatabaseIdRecordRecordIdRouteImport } from './routes/navigation_.$databaseId.record.$recordId'; +import { Route as rootRouteImport } from './routes/__root' +import { Route as WorkspacesRouteImport } from './routes/workspaces' +import { Route as TauriSpikeRouteImport } from './routes/tauri-spike' +import { Route as PingRouteImport } from './routes/ping' +import { Route as NotFoundRouteImport } from './routes/not-found' +import { Route as LoginRouteImport } from './routes/login' +import { Route as IndexRouteImport } from './routes/index' +import { Route as SharesDatabaseIdRouteImport } from './routes/shares.$databaseId' +import { Route as PublicSharesTokenRouteImport } from './routes/public-shares.$token' +import { Route as NavigationDatabaseIdRouteImport } from './routes/navigation.$databaseId' +import { Route as TablesDatabaseIdTableIdRouteImport } from './routes/tables.$databaseId.$tableId' +import { Route as ReportsDatabaseIdTableIdRouteImport } from './routes/reports.$databaseId.$tableId' +import { Route as BuilderDatabaseIdTableIdRouteImport } from './routes/builder.$databaseId.$tableId' +import { Route as NavigationDatabaseIdRecordRecordIdRouteImport } from './routes/navigation_.$databaseId.record.$recordId' const WorkspacesRoute = WorkspacesRouteImport.update({ id: '/workspaces', path: '/workspaces', getParentRoute: () => rootRouteImport, -} as any); +} as any) +const TauriSpikeRoute = TauriSpikeRouteImport.update({ + id: '/tauri-spike', + path: '/tauri-spike', + getParentRoute: () => rootRouteImport, +} as any) const PingRoute = PingRouteImport.update({ id: '/ping', path: '/ping', getParentRoute: () => rootRouteImport, -} as any); +} as any) const NotFoundRoute = NotFoundRouteImport.update({ id: '/not-found', path: '/not-found', getParentRoute: () => rootRouteImport, -} as any); +} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', getParentRoute: () => rootRouteImport, -} as any); +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, -} as any); +} as any) +const SharesDatabaseIdRoute = SharesDatabaseIdRouteImport.update({ + id: '/shares/$databaseId', + path: '/shares/$databaseId', + getParentRoute: () => rootRouteImport, +} as any) +const PublicSharesTokenRoute = PublicSharesTokenRouteImport.update({ + id: '/public-shares/$token', + path: '/public-shares/$token', + getParentRoute: () => rootRouteImport, +} as any) const NavigationDatabaseIdRoute = NavigationDatabaseIdRouteImport.update({ id: '/navigation/$databaseId', path: '/navigation/$databaseId', getParentRoute: () => rootRouteImport, -} as any); +} as any) const TablesDatabaseIdTableIdRoute = TablesDatabaseIdTableIdRouteImport.update({ id: '/tables/$databaseId/$tableId', path: '/tables/$databaseId/$tableId', getParentRoute: () => rootRouteImport, -} as any); -const BuilderDatabaseIdTableIdRoute = BuilderDatabaseIdTableIdRouteImport.update({ - id: '/builder/$databaseId/$tableId', - path: '/builder/$databaseId/$tableId', - getParentRoute: () => rootRouteImport, -} as any); +} as any) +const ReportsDatabaseIdTableIdRoute = + ReportsDatabaseIdTableIdRouteImport.update({ + id: '/reports/$databaseId/$tableId', + path: '/reports/$databaseId/$tableId', + getParentRoute: () => rootRouteImport, + } as any) +const BuilderDatabaseIdTableIdRoute = + BuilderDatabaseIdTableIdRouteImport.update({ + id: '/builder/$databaseId/$tableId', + path: '/builder/$databaseId/$tableId', + getParentRoute: () => rootRouteImport, + } as any) const NavigationDatabaseIdRecordRecordIdRoute = NavigationDatabaseIdRecordRecordIdRouteImport.update({ id: '/navigation_/$databaseId/record/$recordId', path: '/navigation/$databaseId/record/$recordId', getParentRoute: () => rootRouteImport, - } as any); + } as any) export interface FileRoutesByFullPath { - '/': typeof IndexRoute; - '/login': typeof LoginRoute; - '/not-found': typeof NotFoundRoute; - '/ping': typeof PingRoute; - '/workspaces': typeof WorkspacesRoute; - '/navigation/$databaseId': typeof NavigationDatabaseIdRoute; - '/builder/$databaseId/$tableId': typeof BuilderDatabaseIdTableIdRoute; - '/tables/$databaseId/$tableId': typeof TablesDatabaseIdTableIdRoute; - '/navigation/$databaseId/record/$recordId': typeof NavigationDatabaseIdRecordRecordIdRoute; + '/': typeof IndexRoute + '/login': typeof LoginRoute + '/not-found': typeof NotFoundRoute + '/ping': typeof PingRoute + '/tauri-spike': typeof TauriSpikeRoute + '/workspaces': typeof WorkspacesRoute + '/navigation/$databaseId': typeof NavigationDatabaseIdRoute + '/public-shares/$token': typeof PublicSharesTokenRoute + '/shares/$databaseId': typeof SharesDatabaseIdRoute + '/builder/$databaseId/$tableId': typeof BuilderDatabaseIdTableIdRoute + '/reports/$databaseId/$tableId': typeof ReportsDatabaseIdTableIdRoute + '/tables/$databaseId/$tableId': typeof TablesDatabaseIdTableIdRoute + '/navigation/$databaseId/record/$recordId': typeof NavigationDatabaseIdRecordRecordIdRoute } export interface FileRoutesByTo { - '/': typeof IndexRoute; - '/login': typeof LoginRoute; - '/not-found': typeof NotFoundRoute; - '/ping': typeof PingRoute; - '/workspaces': typeof WorkspacesRoute; - '/navigation/$databaseId': typeof NavigationDatabaseIdRoute; - '/builder/$databaseId/$tableId': typeof BuilderDatabaseIdTableIdRoute; - '/tables/$databaseId/$tableId': typeof TablesDatabaseIdTableIdRoute; - '/navigation/$databaseId/record/$recordId': typeof NavigationDatabaseIdRecordRecordIdRoute; + '/': typeof IndexRoute + '/login': typeof LoginRoute + '/not-found': typeof NotFoundRoute + '/ping': typeof PingRoute + '/tauri-spike': typeof TauriSpikeRoute + '/workspaces': typeof WorkspacesRoute + '/navigation/$databaseId': typeof NavigationDatabaseIdRoute + '/public-shares/$token': typeof PublicSharesTokenRoute + '/shares/$databaseId': typeof SharesDatabaseIdRoute + '/builder/$databaseId/$tableId': typeof BuilderDatabaseIdTableIdRoute + '/reports/$databaseId/$tableId': typeof ReportsDatabaseIdTableIdRoute + '/tables/$databaseId/$tableId': typeof TablesDatabaseIdTableIdRoute + '/navigation/$databaseId/record/$recordId': typeof NavigationDatabaseIdRecordRecordIdRoute } export interface FileRoutesById { - __root__: typeof rootRouteImport; - '/': typeof IndexRoute; - '/login': typeof LoginRoute; - '/not-found': typeof NotFoundRoute; - '/ping': typeof PingRoute; - '/workspaces': typeof WorkspacesRoute; - '/navigation/$databaseId': typeof NavigationDatabaseIdRoute; - '/builder/$databaseId/$tableId': typeof BuilderDatabaseIdTableIdRoute; - '/tables/$databaseId/$tableId': typeof TablesDatabaseIdTableIdRoute; - '/navigation_/$databaseId/record/$recordId': typeof NavigationDatabaseIdRecordRecordIdRoute; + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/login': typeof LoginRoute + '/not-found': typeof NotFoundRoute + '/ping': typeof PingRoute + '/tauri-spike': typeof TauriSpikeRoute + '/workspaces': typeof WorkspacesRoute + '/navigation/$databaseId': typeof NavigationDatabaseIdRoute + '/public-shares/$token': typeof PublicSharesTokenRoute + '/shares/$databaseId': typeof SharesDatabaseIdRoute + '/builder/$databaseId/$tableId': typeof BuilderDatabaseIdTableIdRoute + '/reports/$databaseId/$tableId': typeof ReportsDatabaseIdTableIdRoute + '/tables/$databaseId/$tableId': typeof TablesDatabaseIdTableIdRoute + '/navigation_/$databaseId/record/$recordId': typeof NavigationDatabaseIdRecordRecordIdRoute } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath; + fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' | '/login' | '/not-found' | '/ping' + | '/tauri-spike' | '/workspaces' | '/navigation/$databaseId' + | '/public-shares/$token' + | '/shares/$databaseId' | '/builder/$databaseId/$tableId' + | '/reports/$databaseId/$tableId' | '/tables/$databaseId/$tableId' - | '/navigation/$databaseId/record/$recordId'; - fileRoutesByTo: FileRoutesByTo; + | '/navigation/$databaseId/record/$recordId' + fileRoutesByTo: FileRoutesByTo to: | '/' | '/login' | '/not-found' | '/ping' + | '/tauri-spike' | '/workspaces' | '/navigation/$databaseId' + | '/public-shares/$token' + | '/shares/$databaseId' | '/builder/$databaseId/$tableId' + | '/reports/$databaseId/$tableId' | '/tables/$databaseId/$tableId' - | '/navigation/$databaseId/record/$recordId'; + | '/navigation/$databaseId/record/$recordId' id: | '__root__' | '/' | '/login' | '/not-found' | '/ping' + | '/tauri-spike' | '/workspaces' | '/navigation/$databaseId' + | '/public-shares/$token' + | '/shares/$databaseId' | '/builder/$databaseId/$tableId' + | '/reports/$databaseId/$tableId' | '/tables/$databaseId/$tableId' - | '/navigation_/$databaseId/record/$recordId'; - fileRoutesById: FileRoutesById; + | '/navigation_/$databaseId/record/$recordId' + fileRoutesById: FileRoutesById } export interface RootRouteChildren { - IndexRoute: typeof IndexRoute; - LoginRoute: typeof LoginRoute; - NotFoundRoute: typeof NotFoundRoute; - PingRoute: typeof PingRoute; - WorkspacesRoute: typeof WorkspacesRoute; - NavigationDatabaseIdRoute: typeof NavigationDatabaseIdRoute; - BuilderDatabaseIdTableIdRoute: typeof BuilderDatabaseIdTableIdRoute; - TablesDatabaseIdTableIdRoute: typeof TablesDatabaseIdTableIdRoute; - NavigationDatabaseIdRecordRecordIdRoute: typeof NavigationDatabaseIdRecordRecordIdRoute; + IndexRoute: typeof IndexRoute + LoginRoute: typeof LoginRoute + NotFoundRoute: typeof NotFoundRoute + PingRoute: typeof PingRoute + TauriSpikeRoute: typeof TauriSpikeRoute + WorkspacesRoute: typeof WorkspacesRoute + NavigationDatabaseIdRoute: typeof NavigationDatabaseIdRoute + PublicSharesTokenRoute: typeof PublicSharesTokenRoute + SharesDatabaseIdRoute: typeof SharesDatabaseIdRoute + BuilderDatabaseIdTableIdRoute: typeof BuilderDatabaseIdTableIdRoute + ReportsDatabaseIdTableIdRoute: typeof ReportsDatabaseIdTableIdRoute + TablesDatabaseIdTableIdRoute: typeof TablesDatabaseIdTableIdRoute + NavigationDatabaseIdRecordRecordIdRoute: typeof NavigationDatabaseIdRecordRecordIdRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { '/workspaces': { - id: '/workspaces'; - path: '/workspaces'; - fullPath: '/workspaces'; - preLoaderRoute: typeof WorkspacesRouteImport; - parentRoute: typeof rootRouteImport; - }; + id: '/workspaces' + path: '/workspaces' + fullPath: '/workspaces' + preLoaderRoute: typeof WorkspacesRouteImport + parentRoute: typeof rootRouteImport + } + '/tauri-spike': { + id: '/tauri-spike' + path: '/tauri-spike' + fullPath: '/tauri-spike' + preLoaderRoute: typeof TauriSpikeRouteImport + parentRoute: typeof rootRouteImport + } '/ping': { - id: '/ping'; - path: '/ping'; - fullPath: '/ping'; - preLoaderRoute: typeof PingRouteImport; - parentRoute: typeof rootRouteImport; - }; + id: '/ping' + path: '/ping' + fullPath: '/ping' + preLoaderRoute: typeof PingRouteImport + parentRoute: typeof rootRouteImport + } '/not-found': { - id: '/not-found'; - path: '/not-found'; - fullPath: '/not-found'; - preLoaderRoute: typeof NotFoundRouteImport; - parentRoute: typeof rootRouteImport; - }; + id: '/not-found' + path: '/not-found' + fullPath: '/not-found' + preLoaderRoute: typeof NotFoundRouteImport + parentRoute: typeof rootRouteImport + } '/login': { - id: '/login'; - path: '/login'; - fullPath: '/login'; - preLoaderRoute: typeof LoginRouteImport; - parentRoute: typeof rootRouteImport; - }; + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } '/': { - id: '/'; - path: '/'; - fullPath: '/'; - preLoaderRoute: typeof IndexRouteImport; - parentRoute: typeof rootRouteImport; - }; + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/shares/$databaseId': { + id: '/shares/$databaseId' + path: '/shares/$databaseId' + fullPath: '/shares/$databaseId' + preLoaderRoute: typeof SharesDatabaseIdRouteImport + parentRoute: typeof rootRouteImport + } + '/public-shares/$token': { + id: '/public-shares/$token' + path: '/public-shares/$token' + fullPath: '/public-shares/$token' + preLoaderRoute: typeof PublicSharesTokenRouteImport + parentRoute: typeof rootRouteImport + } '/navigation/$databaseId': { - id: '/navigation/$databaseId'; - path: '/navigation/$databaseId'; - fullPath: '/navigation/$databaseId'; - preLoaderRoute: typeof NavigationDatabaseIdRouteImport; - parentRoute: typeof rootRouteImport; - }; + id: '/navigation/$databaseId' + path: '/navigation/$databaseId' + fullPath: '/navigation/$databaseId' + preLoaderRoute: typeof NavigationDatabaseIdRouteImport + parentRoute: typeof rootRouteImport + } '/tables/$databaseId/$tableId': { - id: '/tables/$databaseId/$tableId'; - path: '/tables/$databaseId/$tableId'; - fullPath: '/tables/$databaseId/$tableId'; - preLoaderRoute: typeof TablesDatabaseIdTableIdRouteImport; - parentRoute: typeof rootRouteImport; - }; + id: '/tables/$databaseId/$tableId' + path: '/tables/$databaseId/$tableId' + fullPath: '/tables/$databaseId/$tableId' + preLoaderRoute: typeof TablesDatabaseIdTableIdRouteImport + parentRoute: typeof rootRouteImport + } + '/reports/$databaseId/$tableId': { + id: '/reports/$databaseId/$tableId' + path: '/reports/$databaseId/$tableId' + fullPath: '/reports/$databaseId/$tableId' + preLoaderRoute: typeof ReportsDatabaseIdTableIdRouteImport + parentRoute: typeof rootRouteImport + } '/builder/$databaseId/$tableId': { - id: '/builder/$databaseId/$tableId'; - path: '/builder/$databaseId/$tableId'; - fullPath: '/builder/$databaseId/$tableId'; - preLoaderRoute: typeof BuilderDatabaseIdTableIdRouteImport; - parentRoute: typeof rootRouteImport; - }; + id: '/builder/$databaseId/$tableId' + path: '/builder/$databaseId/$tableId' + fullPath: '/builder/$databaseId/$tableId' + preLoaderRoute: typeof BuilderDatabaseIdTableIdRouteImport + parentRoute: typeof rootRouteImport + } '/navigation_/$databaseId/record/$recordId': { - id: '/navigation_/$databaseId/record/$recordId'; - path: '/navigation/$databaseId/record/$recordId'; - fullPath: '/navigation/$databaseId/record/$recordId'; - preLoaderRoute: typeof NavigationDatabaseIdRecordRecordIdRouteImport; - parentRoute: typeof rootRouteImport; - }; + id: '/navigation_/$databaseId/record/$recordId' + path: '/navigation/$databaseId/record/$recordId' + fullPath: '/navigation/$databaseId/record/$recordId' + preLoaderRoute: typeof NavigationDatabaseIdRecordRecordIdRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -221,12 +303,17 @@ const rootRouteChildren: RootRouteChildren = { LoginRoute: LoginRoute, NotFoundRoute: NotFoundRoute, PingRoute: PingRoute, + TauriSpikeRoute: TauriSpikeRoute, WorkspacesRoute: WorkspacesRoute, NavigationDatabaseIdRoute: NavigationDatabaseIdRoute, + PublicSharesTokenRoute: PublicSharesTokenRoute, + SharesDatabaseIdRoute: SharesDatabaseIdRoute, BuilderDatabaseIdTableIdRoute: BuilderDatabaseIdTableIdRoute, + ReportsDatabaseIdTableIdRoute: ReportsDatabaseIdTableIdRoute, TablesDatabaseIdTableIdRoute: TablesDatabaseIdTableIdRoute, - NavigationDatabaseIdRecordRecordIdRoute: NavigationDatabaseIdRecordRecordIdRoute, -}; + NavigationDatabaseIdRecordRecordIdRoute: + NavigationDatabaseIdRecordRecordIdRoute, +} export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) - ._addFileTypes(); + ._addFileTypes() diff --git a/apps/client/src/routes/__root.tsx b/apps/client/src/routes/__root.tsx index 12ead59..128c3a1 100644 --- a/apps/client/src/routes/__root.tsx +++ b/apps/client/src/routes/__root.tsx @@ -21,6 +21,9 @@ function RootLayout() { const [isNavigationOpen, setIsNavigationOpen] = useState(false); const isLoginPage = location.pathname === '/login'; + const isPublicSharePage = location.pathname.startsWith('/public-shares/'); + const isTauriSpikePage = location.pathname === '/tauri-spike'; + const isBarePage = isLoginPage || isPublicSharePage || isTauriSpikePage; interface Database { id: string; @@ -48,14 +51,14 @@ function RootLayout() { }); useEffect(() => { - if (isLoading || isAuthenticated || isLoginPage) return; + if (isLoading || isAuthenticated || isBarePage) return; const returnTo = location.pathname !== '/' ? location.pathname : null; navigate({ to: '/login', search: returnTo ? { returnTo } : undefined }); - }, [isAuthenticated, isLoading, isLoginPage, location.pathname, navigate]); + }, [isAuthenticated, isLoading, isBarePage, location.pathname, navigate]); // The redirect effect owns navigation; avoid rendering protected content while it runs. - if (!isLoading && !isAuthenticated && !isLoginPage) { + if (!isLoading && !isAuthenticated && !isBarePage) { return null; } @@ -70,7 +73,7 @@ function RootLayout() { ); } - if (isLoginPage) { + if (isBarePage) { return ; } diff --git a/apps/client/src/routes/navigation.$databaseId.tsx b/apps/client/src/routes/navigation.$databaseId.tsx index e63e43a..4dcb716 100644 --- a/apps/client/src/routes/navigation.$databaseId.tsx +++ b/apps/client/src/routes/navigation.$databaseId.tsx @@ -11,7 +11,7 @@ import { parseGpsValue } from '../lib/fieldDisplay'; import { type BuilderField } from '../lib/fieldTypes'; import { LoadingSpinner } from '../components/LoadingSpinner'; import { EmptyState } from '../components/ui/EmptyState'; -import { PageHeader } from '../components/ui/PageHeader'; +import { PageHeader, PageActions } from '../components/ui/PageHeader'; import { SurfaceCard } from '../components/ui/SurfaceCard'; import { loadBundledGazetteerResolver, type LocalitySnapshot } from '@hap/core'; @@ -580,7 +580,24 @@ function NavigationModePage() { } `} - + + +