From 4e545fd77ac37058810cae3f7da97e0a80cbe59f Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Tue, 3 Mar 2026 05:50:47 -0500 Subject: [PATCH 1/9] feat: add reusable template builder system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a foundational, reusable template builder system that supports designing invoice templates, shipping label templates, report templates, receipts, and other documents. New files: - migrations/2026_03_03_000001_create_templates_table.php - migrations/2026_03_03_000002_create_template_queries_table.php - src/Models/Template.php - src/Models/TemplateQuery.php - src/Http/Resources/Template.php - src/Http/Resources/TemplateQuery.php - src/Http/Requests/Internal/CreateTemplateRequest.php - src/Http/Controllers/Internal/v1/TemplateController.php - src/Http/Controllers/Internal/v1/TemplateQueryController.php - src/Services/TemplateRenderService.php Modified files: - src/routes.php — registers templates and template-queries routes - src/Providers/CoreServiceProvider.php — registers TemplateRenderService singleton - composer.json — adds spatie/laravel-pdf ^2.0 and mossadal/math-parser ^2.0 Key features: - Three-tier context: global/ambient (company, user, date) + primary subject + query collections - Variable syntax: {namespace.property} with dot-notation - Formula syntax: [{ {invoice.subtotal} * 1.1 }] with safe arithmetic evaluation - Iteration blocks: {{#each variable}} ... {{/each}} with {this.property}, {loop.index} - Canvas-based element system: text, image, table, line, shape, qr_code, barcode - PDF output via spatie/laravel-pdf; HTML preview endpoint - registerContextType() static method for extensions to register context types --- composer.json | 6 +- ...26_03_03_000001_create_templates_table.php | 72 ++ ...3_000002_create_template_queries_table.php | 61 ++ .../Internal/v1/TemplateController.php | 110 +++ .../Internal/v1/TemplateQueryController.php | 13 + .../Internal/CreateTemplateRequest.php | 43 ++ src/Http/Resources/Template.php | 46 ++ src/Http/Resources/TemplateQuery.php | 35 + src/Models/Template.php | 153 +++++ src/Models/TemplateQuery.php | 172 +++++ src/Providers/CoreServiceProvider.php | 5 + src/Services/TemplateRenderService.php | 642 ++++++++++++++++++ src/routes.php | 6 + 13 files changed, 1362 insertions(+), 2 deletions(-) create mode 100644 migrations/2026_03_03_000001_create_templates_table.php create mode 100644 migrations/2026_03_03_000002_create_template_queries_table.php create mode 100644 src/Http/Controllers/Internal/v1/TemplateController.php create mode 100644 src/Http/Controllers/Internal/v1/TemplateQueryController.php create mode 100644 src/Http/Requests/Internal/CreateTemplateRequest.php create mode 100644 src/Http/Resources/Template.php create mode 100644 src/Http/Resources/TemplateQuery.php create mode 100644 src/Models/Template.php create mode 100644 src/Models/TemplateQuery.php create mode 100644 src/Services/TemplateRenderService.php diff --git a/composer.json b/composer.json index 1efb0e0d..84aaec16 100644 --- a/composer.json +++ b/composer.json @@ -55,7 +55,9 @@ "spatie/laravel-schedule-monitor": "^3.7", "spatie/laravel-sluggable": "^3.5", "sqids/sqids": "^0.4.1", - "xantios/mimey": "^2.2.0" + "xantios/mimey": "^2.2.0", + "spatie/laravel-pdf": "^2.0", + "mossadal/math-parser": "^2.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.34.1", @@ -105,4 +107,4 @@ "@test:unit" ] } -} +} \ No newline at end of file diff --git a/migrations/2026_03_03_000001_create_templates_table.php b/migrations/2026_03_03_000001_create_templates_table.php new file mode 100644 index 00000000..e922de65 --- /dev/null +++ b/migrations/2026_03_03_000001_create_templates_table.php @@ -0,0 +1,72 @@ +bigIncrements('id'); + $table->string('uuid', 191)->unique()->nullable(); + $table->string('public_id', 191)->unique()->nullable(); + $table->string('company_uuid', 191)->nullable()->index(); + $table->string('created_by_uuid', 191)->nullable()->index(); + $table->string('updated_by_uuid', 191)->nullable()->index(); + + // Identity + $table->string('name'); + $table->text('description')->nullable(); + + // Context type — defines which Fleetbase model this template is designed for. + // e.g. 'order', 'invoice', 'transaction', 'shipping_label', 'receipt', 'report' + $table->string('context_type')->default('generic')->index(); + + // Canvas dimensions (in mm by default, configurable via unit) + $table->string('unit')->default('mm'); // mm, px, in + $table->decimal('width', 10, 4)->default(210); // A4 width + $table->decimal('height', 10, 4)->default(297); // A4 height + $table->string('orientation')->default('portrait'); // portrait | landscape + + // Page settings + $table->json('margins')->nullable(); // { top, right, bottom, left } + $table->string('background_color')->nullable(); + $table->string('background_image_uuid')->nullable(); + + // The full template content — array of element objects + $table->longText('content')->nullable(); // JSON array of TemplateElement objects + + // Element type definitions / schema overrides (optional per-template customisation) + $table->json('element_schemas')->nullable(); + + // Status + $table->boolean('is_default')->default(false); + $table->boolean('is_system')->default(false); + $table->boolean('is_public')->default(false); + + $table->timestamps(); + $table->softDeletes(); + + // Foreign keys + $table->foreign('company_uuid')->references('uuid')->on('companies')->onDelete('cascade'); + $table->foreign('created_by_uuid')->references('uuid')->on('users')->onDelete('set null'); + $table->foreign('updated_by_uuid')->references('uuid')->on('users')->onDelete('set null'); + + // Composite indexes + $table->index(['company_uuid', 'context_type']); + $table->index(['company_uuid', 'is_default']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('templates'); + } +}; diff --git a/migrations/2026_03_03_000002_create_template_queries_table.php b/migrations/2026_03_03_000002_create_template_queries_table.php new file mode 100644 index 00000000..9ad61063 --- /dev/null +++ b/migrations/2026_03_03_000002_create_template_queries_table.php @@ -0,0 +1,61 @@ +bigIncrements('id'); + $table->string('uuid', 191)->unique()->nullable(); + $table->string('public_id', 191)->unique()->nullable(); + $table->string('company_uuid', 191)->nullable()->index(); + $table->string('template_uuid', 191)->nullable()->index(); + $table->string('created_by_uuid', 191)->nullable()->index(); + + // The fully-qualified model class this query targets + // e.g. 'Fleetbase\Models\Order', 'Fleetbase\FleetOps\Models\Order' + $table->string('model_type'); + + // The variable name used in the template to access this collection + // e.g. 'orders', 'transactions', 'invoices' + $table->string('variable_name'); + + // Human-readable label shown in the variable picker + $table->string('label')->nullable(); + + // JSON array of filter condition groups + // Each condition: { field, operator, value, type } + $table->json('conditions')->nullable(); + + // JSON array of sort directives: [{ field, direction }] + $table->json('sort')->nullable(); + + // Maximum number of records to return (null = no limit) + $table->unsignedInteger('limit')->nullable(); + + // Which relationships to eager-load on the result set + $table->json('with')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('company_uuid')->references('uuid')->on('companies')->onDelete('cascade'); + $table->foreign('template_uuid')->references('uuid')->on('templates')->onDelete('cascade'); + $table->foreign('created_by_uuid')->references('uuid')->on('users')->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('template_queries'); + } +}; diff --git a/src/Http/Controllers/Internal/v1/TemplateController.php b/src/Http/Controllers/Internal/v1/TemplateController.php new file mode 100644 index 00000000..4196182b --- /dev/null +++ b/src/Http/Controllers/Internal/v1/TemplateController.php @@ -0,0 +1,110 @@ +renderService = $renderService; + } + + /** + * Render a template to HTML for preview. + * + * POST /templates/{id}/preview + * + * Body: + * subject_type (string, optional) — fully-qualified model class + * subject_id (string, optional) — UUID or public_id of the subject record + */ + public function preview(string $id, Request $request): JsonResponse + { + $template = Template::where('uuid', $id) + ->orWhere('public_id', $id) + ->firstOrFail(); + + $subjectType = $request->input('subject_type'); + $subjectId = $request->input('subject_id'); + $subject = null; + + if ($subjectType && $subjectId && class_exists($subjectType)) { + $subject = $subjectType::where('uuid', $subjectId) + ->orWhere('public_id', $subjectId) + ->first(); + } + + $html = $this->renderService->renderToHtml($template, $subject); + + return response()->json(['html' => $html]); + } + + /** + * Render a template to a PDF and stream it as a download. + * + * POST /templates/{id}/render + * + * Body: + * subject_type (string, optional) + * subject_id (string, optional) + * filename (string, optional) — defaults to template name + */ + public function render(string $id, Request $request): Response + { + $template = Template::where('uuid', $id) + ->orWhere('public_id', $id) + ->firstOrFail(); + + $subjectType = $request->input('subject_type'); + $subjectId = $request->input('subject_id'); + $filename = $request->input('filename', $template->name); + $subject = null; + + if ($subjectType && $subjectId && class_exists($subjectType)) { + $subject = $subjectType::where('uuid', $subjectId) + ->orWhere('public_id', $subjectId) + ->first(); + } + + $pdf = $this->renderService->renderToPdf($template, $subject); + + return $pdf->download($filename . '.pdf'); + } + + /** + * Return the available context types and their variable schemas. + * Used by the frontend variable picker. + * + * GET /templates/context-schemas + */ + public function contextSchemas(): JsonResponse + { + $schemas = $this->renderService->getContextSchemas(); + + return response()->json(['schemas' => $schemas]); + } +} diff --git a/src/Http/Controllers/Internal/v1/TemplateQueryController.php b/src/Http/Controllers/Internal/v1/TemplateQueryController.php new file mode 100644 index 00000000..dc1c246c --- /dev/null +++ b/src/Http/Controllers/Internal/v1/TemplateQueryController.php @@ -0,0 +1,13 @@ +session()->has('company'); + } + + /** + * Get the validation rules that apply to the request. + */ + public function rules(): array + { + return [ + 'name' => 'required|min:2|max:191', + 'context_type' => 'required|string|max:191', + 'orientation' => 'nullable|in:portrait,landscape', + 'unit' => 'nullable|in:mm,px,in', + 'width' => 'nullable|numeric|min:1', + 'height' => 'nullable|numeric|min:1', + ]; + } + + /** + * Get custom messages for validator errors. + */ + public function messages(): array + { + return [ + 'name.required' => 'A template name is required.', + 'name.min' => 'The template name must be at least 2 characters.', + 'context_type.required' => 'A context type is required to determine which variables are available.', + ]; + } +} diff --git a/src/Http/Resources/Template.php b/src/Http/Resources/Template.php new file mode 100644 index 00000000..1ae0747c --- /dev/null +++ b/src/Http/Resources/Template.php @@ -0,0 +1,46 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), + 'company_uuid' => $this->when(Http::isInternalRequest(), $this->company_uuid), + 'created_by_uuid' => $this->when(Http::isInternalRequest(), $this->created_by_uuid), + 'updated_by_uuid' => $this->when(Http::isInternalRequest(), $this->updated_by_uuid), + 'background_image_uuid' => $this->when(Http::isInternalRequest(), $this->background_image_uuid), + 'name' => $this->name, + 'description' => $this->description, + 'context_type' => $this->context_type, + 'unit' => $this->unit, + 'width' => $this->width, + 'height' => $this->height, + 'orientation' => $this->orientation, + 'margins' => $this->margins, + 'background_color' => $this->background_color, + 'background_image' => $this->whenLoaded('backgroundImage', fn () => new File($this->backgroundImage)), + 'content' => $this->content ?? [], + 'element_schemas' => $this->element_schemas ?? [], + 'queries' => TemplateQuery::collection($this->whenLoaded('queries')), + 'is_default' => $this->is_default, + 'is_system' => $this->is_system, + 'is_public' => $this->is_public, + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/src/Http/Resources/TemplateQuery.php b/src/Http/Resources/TemplateQuery.php new file mode 100644 index 00000000..a0d13fd4 --- /dev/null +++ b/src/Http/Resources/TemplateQuery.php @@ -0,0 +1,35 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), + 'company_uuid' => $this->when(Http::isInternalRequest(), $this->company_uuid), + 'template_uuid' => $this->when(Http::isInternalRequest(), $this->template_uuid), + 'model_type' => $this->model_type, + 'variable_name' => $this->variable_name, + 'label' => $this->label, + 'conditions' => $this->conditions ?? [], + 'sort' => $this->sort ?? [], + 'limit' => $this->limit, + 'with' => $this->with ?? [], + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/src/Models/Template.php b/src/Models/Template.php new file mode 100644 index 00000000..97481cf7 --- /dev/null +++ b/src/Models/Template.php @@ -0,0 +1,153 @@ + Json::class, + 'content' => Json::class, + 'element_schemas' => Json::class, + 'is_default' => 'boolean', + 'is_system' => 'boolean', + 'is_public' => 'boolean', + 'width' => 'float', + 'height' => 'float', + ]; + + /** + * The attributes that should be hidden for serialization. + */ + protected $hidden = ['id']; + + /** + * Dynamic attributes appended to the model. + */ + protected $appends = []; + + /** + * The company this template belongs to. + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_uuid', 'uuid'); + } + + /** + * The user who created this template. + */ + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_uuid', 'uuid'); + } + + /** + * The user who last updated this template. + */ + public function updatedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by_uuid', 'uuid'); + } + + /** + * The background image file for this template. + */ + public function backgroundImage(): BelongsTo + { + return $this->belongsTo(File::class, 'background_image_uuid', 'uuid'); + } + + /** + * The query data sources attached to this template. + */ + public function queries(): HasMany + { + return $this->hasMany(TemplateQuery::class, 'template_uuid', 'uuid'); + } + + /** + * Scope to filter templates by context type. + */ + public function scopeForContext($query, string $contextType) + { + return $query->where('context_type', $contextType); + } + + /** + * Scope to include system and company templates. + */ + public function scopeAvailableFor($query, string $companyUuid) + { + return $query->where(function ($q) use ($companyUuid) { + $q->where('company_uuid', $companyUuid) + ->orWhere('is_system', true) + ->orWhere('is_public', true); + }); + } +} diff --git a/src/Models/TemplateQuery.php b/src/Models/TemplateQuery.php new file mode 100644 index 00000000..fdbc8d81 --- /dev/null +++ b/src/Models/TemplateQuery.php @@ -0,0 +1,172 @@ + Json::class, + 'sort' => Json::class, + 'with' => Json::class, + 'limit' => 'integer', + ]; + + /** + * The attributes that should be hidden for serialization. + */ + protected $hidden = ['id']; + + /** + * The template this query belongs to. + */ + public function template(): BelongsTo + { + return $this->belongsTo(Template::class, 'template_uuid', 'uuid'); + } + + /** + * The company this query belongs to. + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_uuid', 'uuid'); + } + + /** + * The user who created this query. + */ + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_uuid', 'uuid'); + } + + /** + * Execute this query and return the result collection. + * + * Conditions are evaluated against the model class. Each condition object: + * { field, operator, value, type } + * + * Supported operators: =, !=, >, >=, <, <=, like, not like, in, not in, null, not null + */ + public function execute(): \Illuminate\Support\Collection + { + $modelClass = $this->model_type; + + if (!class_exists($modelClass)) { + return collect(); + } + + $query = $modelClass::query(); + + // Apply company scope if the model supports it + if (isset((new $modelClass())->fillable) && in_array('company_uuid', (new $modelClass())->getFillable())) { + $query->where('company_uuid', $this->company_uuid); + } + + // Apply filter conditions + foreach ($this->conditions ?? [] as $condition) { + $field = data_get($condition, 'field'); + $operator = data_get($condition, 'operator', '='); + $value = data_get($condition, 'value'); + + if (!$field) { + continue; + } + + switch (strtolower($operator)) { + case 'in': + $query->whereIn($field, (array) $value); + break; + case 'not in': + $query->whereNotIn($field, (array) $value); + break; + case 'null': + $query->whereNull($field); + break; + case 'not null': + $query->whereNotNull($field); + break; + case 'like': + $query->where($field, 'LIKE', '%' . $value . '%'); + break; + case 'not like': + $query->where($field, 'NOT LIKE', '%' . $value . '%'); + break; + default: + $query->where($field, $operator, $value); + break; + } + } + + // Apply sort + foreach ($this->sort ?? [] as $sortDirective) { + $field = data_get($sortDirective, 'field'); + $direction = data_get($sortDirective, 'direction', 'asc'); + if ($field) { + $query->orderBy($field, $direction); + } + } + + // Apply limit + if ($this->limit) { + $query->limit($this->limit); + } + + // Eager-load relationships + if (!empty($this->with)) { + $query->with($this->with); + } + + return $query->get(); + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 817da678..9e3ed4b6 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -140,6 +140,11 @@ public function register() $this->app->singleton(\Fleetbase\Services\FileResolverService::class, function ($app) { return new \Fleetbase\Services\FileResolverService(); }); + + // register template render service + $this->app->singleton(\Fleetbase\Services\TemplateRenderService::class, function ($app) { + return new \Fleetbase\Services\TemplateRenderService(); + }); } /** diff --git a/src/Services/TemplateRenderService.php b/src/Services/TemplateRenderService.php new file mode 100644 index 00000000..d92d5660 --- /dev/null +++ b/src/Services/TemplateRenderService.php @@ -0,0 +1,642 @@ + [ + 'label' => 'Generic', + 'description' => 'No primary subject — only global variables are available.', + 'model' => null, + 'variables' => [], + ], + ]; + + /** + * Register a context type so the frontend variable picker knows about it. + * + * @param string $slug e.g. 'invoice', 'order', 'shipping_label' + * @param array $definition Keys: label, description, model (FQCN), variables (array of { name, path, type, description }) + */ + public static function registerContextType(string $slug, array $definition): void + { + static::$contextTypes[$slug] = $definition; + } + + /** + * Return all registered context type schemas. + */ + public function getContextSchemas(): array + { + // Always prepend the global variables that are available in every context + $globalVariables = $this->getGlobalVariableSchema(); + + return array_map(function ($definition) use ($globalVariables) { + $definition['global_variables'] = $globalVariables; + + return $definition; + }, static::$contextTypes); + } + + /** + * Render a template to an HTML string. + * + * @param Template $template The template to render. + * @param Model|null $subject The primary data subject (e.g. an Invoice or Order model instance). + */ + public function renderToHtml(Template $template, ?Model $subject = null): string + { + $context = $this->buildContext($template, $subject); + $html = $this->buildHtmlFromContent($template); + $html = $this->processIterationBlocks($html, $context); + $html = $this->evaluateFormulas($html, $context); + $html = $this->substituteVariables($html, $context); + + return $this->wrapInDocument($html, $template); + } + + /** + * Render a template to a PDF response using spatie/laravel-pdf. + * + * @param Template $template + * @param Model|null $subject + * + * @return \Spatie\LaravelPdf\PdfBuilder + */ + public function renderToPdf(Template $template, ?Model $subject = null) + { + $html = $this->renderToHtml($template, $subject); + + // spatie/laravel-pdf — driver resolved from config (dompdf by default) + return \Spatie\LaravelPdf\Facades\Pdf::html($html) + ->paperSize($template->width, $template->height, $template->unit) + ->margins( + data_get($template->margins, 'top', 10), + data_get($template->margins, 'right', 10), + data_get($template->margins, 'bottom', 10), + data_get($template->margins, 'left', 10), + $template->unit + ); + } + + // ------------------------------------------------------------------------- + // Context building + // ------------------------------------------------------------------------- + + /** + * Build the full merged variable context for a render operation. + * + * Context tiers (later tiers override earlier on conflict): + * 1. Global / ambient (company, user, date) + * 2. Primary subject (the passed $subject model) + * 3. Query-based collections (from TemplateQuery records) + */ + protected function buildContext(Template $template, ?Model $subject = null): array + { + $context = []; + + // Tier 1: Global ambient context + $context = array_merge($context, $this->buildGlobalContext()); + + // Tier 2: Primary subject context + if ($subject !== null) { + $subjectKey = $template->context_type !== 'generic' ? $template->context_type : $this->guessContextKey($subject); + $context[$subjectKey] = $this->modelToArray($subject); + } + + // Tier 3: Query-based collections + $template->loadMissing('queries'); + foreach ($template->queries as $templateQuery) { + $results = $templateQuery->execute(); + $context[$templateQuery->variable_name] = $results->map(fn ($item) => $this->modelToArray($item))->toArray(); + } + + return $context; + } + + /** + * Build the global/ambient context that is always available in every template. + * + * Variables: + * {now} — current ISO 8601 datetime + * {today} — current date (Y-m-d) + * {time} — current time (H:i:s) + * {year} — current 4-digit year + * {company.*} — current session company attributes + * {user.*} — current authenticated user attributes + */ + protected function buildGlobalContext(): array + { + $now = Carbon::now(); + + $context = [ + 'now' => $now->toIso8601String(), + 'today' => $now->toDateString(), + 'time' => $now->toTimeString(), + 'year' => $now->year, + ]; + + // Company context from session + $companyUuid = Session::get('company'); + if ($companyUuid) { + $company = \Fleetbase\Models\Company::where('uuid', $companyUuid)->first(); + if ($company) { + $context['company'] = $this->modelToArray($company); + } + } + + // Authenticated user context + $user = Auth::user(); + if ($user) { + $context['user'] = $this->modelToArray($user); + } + + return $context; + } + + /** + * Return the schema of global variables for the frontend variable picker. + */ + protected function getGlobalVariableSchema(): array + { + return [ + ['name' => 'Current Date & Time', 'path' => 'now', 'type' => 'string', 'description' => 'ISO 8601 timestamp of the render time.'], + ['name' => 'Today\'s Date', 'path' => 'today', 'type' => 'string', 'description' => 'Current date in Y-m-d format.'], + ['name' => 'Current Time', 'path' => 'time', 'type' => 'string', 'description' => 'Current time in H:i:s format.'], + ['name' => 'Current Year', 'path' => 'year', 'type' => 'integer', 'description' => 'Current 4-digit year.'], + ['name' => 'Company Name', 'path' => 'company.name', 'type' => 'string', 'description' => 'Name of the current organisation.'], + ['name' => 'Company Email', 'path' => 'company.email','type' => 'string', 'description' => 'Primary email of the current organisation.'], + ['name' => 'Company Phone', 'path' => 'company.phone','type' => 'string', 'description' => 'Phone number of the current organisation.'], + ['name' => 'Company Address', 'path' => 'company.address', 'type' => 'string', 'description' => 'Street address of the current organisation.'], + ['name' => 'User Name', 'path' => 'user.name', 'type' => 'string', 'description' => 'Full name of the authenticated user.'], + ['name' => 'User Email', 'path' => 'user.email', 'type' => 'string', 'description' => 'Email address of the authenticated user.'], + ]; + } + + // ------------------------------------------------------------------------- + // HTML generation from template content + // ------------------------------------------------------------------------- + + /** + * Convert the template's content (array of element objects) into an HTML string. + * Each element is absolutely positioned within the canvas. + */ + protected function buildHtmlFromContent(Template $template): string + { + $elements = $template->content ?? []; + $html = ''; + + foreach ($elements as $element) { + $html .= $this->renderElement($element); + } + + return $html; + } + + /** + * Render a single element object to its HTML representation. + * + * Element object shape: + * { + * id, type, x, y, width, height, rotation, + * content, styles, attributes + * } + */ + protected function renderElement(array $element): string + { + $type = data_get($element, 'type', 'text'); + $x = data_get($element, 'x', 0); + $y = data_get($element, 'y', 0); + $width = data_get($element, 'width', 100); + $height = data_get($element, 'height', 'auto'); + $rotation = data_get($element, 'rotation', 0); + $styles = data_get($element, 'styles', []); + $content = data_get($element, 'content', ''); + + // Build inline style string + $styleStr = $this->buildInlineStyle(array_merge([ + 'position' => 'absolute', + 'left' => $x . 'px', + 'top' => $y . 'px', + 'width' => is_numeric($width) ? $width . 'px' : $width, + 'height' => is_numeric($height) ? $height . 'px' : $height, + 'transform' => $rotation ? "rotate({$rotation}deg)" : null, + ], $styles)); + + switch ($type) { + case 'text': + case 'heading': + case 'paragraph': + return "
{$content}
\n"; + + case 'image': + $src = data_get($element, 'src', ''); + + return "\"\"\n"; + + case 'table': + return $this->renderTableElement($element, $styleStr); + + case 'line': + $borderStyle = $this->buildInlineStyle([ + 'position' => 'absolute', + 'left' => $x . 'px', + 'top' => $y . 'px', + 'width' => is_numeric($width) ? $width . 'px' : $width, + 'border-top' => data_get($styles, 'borderTop', '1px solid #000'), + ]); + + return "
\n"; + + case 'rectangle': + case 'shape': + return "
\n"; + + case 'qr_code': + // QR code is rendered as an img with a data URL generated at render time + $value = data_get($element, 'value', ''); + + return "
\n"; + + case 'barcode': + $value = data_get($element, 'value', ''); + + return "
\n"; + + default: + return "
{$content}
\n"; + } + } + + /** + * Render a table element from its column/row definitions. + * + * Table element shape: + * { + * columns: [{ label, key, width }], + * rows: [ {key: value, ...} ] OR data_source: 'variable_name' + * } + */ + protected function renderTableElement(array $element, string $styleStr): string + { + $columns = data_get($element, 'columns', []); + $rows = data_get($element, 'rows', []); + $dataSource = data_get($element, 'data_source'); // variable name for dynamic rows + + $tableStyle = $this->buildInlineStyle([ + 'width' => '100%', + 'border-collapse' => 'collapse', + ]); + + $html = "\n\n\n"; + + foreach ($columns as $col) { + $label = data_get($col, 'label', ''); + $colWidth = data_get($col, 'width', 'auto'); + $colStyle = $colWidth !== 'auto' ? " style=\"width:{$colWidth}\"" : ''; + $html .= "{$label}\n"; + } + + $html .= "\n\n\n"; + + // If the table has a dynamic data source, emit an each block placeholder + // that will be resolved during the iteration pass + if ($dataSource) { + $html .= "{{#each {$dataSource}}}\n\n"; + foreach ($columns as $col) { + $key = data_get($col, 'key', ''); + $html .= "\n"; + } + $html .= "\n{{/each}}\n"; + } else { + foreach ($rows as $row) { + $html .= "\n"; + foreach ($columns as $col) { + $key = data_get($col, 'key', ''); + $value = data_get($row, $key, ''); + $html .= "\n"; + } + $html .= "\n"; + } + } + + $html .= "\n
{this.{$key}}
{$value}
\n"; + + return $html; + } + + // ------------------------------------------------------------------------- + // Rendering passes + // ------------------------------------------------------------------------- + + /** + * Pass 1: Process {{#each variable}} ... {{/each}} iteration blocks. + * + * Within a block, {this.property} refers to the current iteration item. + * Nested each blocks are not supported in this implementation. + */ + protected function processIterationBlocks(string $html, array $context): string + { + $pattern = '/\{\{#each\s+(\w+)\}\}(.*?)\{\{\/each\}\}/s'; + + return preg_replace_callback($pattern, function (array $matches) use ($context) { + $variableName = $matches[1]; + $blockContent = $matches[2]; + $collection = data_get($context, $variableName, []); + + if (empty($collection) || !is_array($collection)) { + return ''; + } + + $output = ''; + $total = count($collection); + + foreach ($collection as $index => $item) { + $itemHtml = $blockContent; + + // Replace {this.property} with the item's values + $itemHtml = preg_replace_callback('/\{this\.([^}]+)\}/', function ($m) use ($item) { + return data_get($item, $m[1], ''); + }, $itemHtml); + + // Replace {loop.index}, {loop.first}, {loop.last} + $itemHtml = str_replace('{loop.index}', $index, $itemHtml); + $itemHtml = str_replace('{loop.first}', $index === 0 ? 'true' : 'false', $itemHtml); + $itemHtml = str_replace('{loop.last}', $index === $total - 1 ? 'true' : 'false', $itemHtml); + + $output .= $itemHtml; + } + + return $output; + }, $html); + } + + /** + * Pass 2: Evaluate formula expressions. + * + * Syntax: [{ expression }] + * Example: [{ {invoice.subtotal} * 1.1 }] + * + * The expression is first variable-substituted (scalars only), then + * evaluated using a safe arithmetic evaluator. + */ + protected function evaluateFormulas(string $html, array $context): string + { + $pattern = '/\[\{\s*(.*?)\s*\}\]/s'; + + return preg_replace_callback($pattern, function (array $matches) use ($context) { + $expression = $matches[1]; + + // Substitute variables within the expression first + $expression = preg_replace_callback('/\{([^}]+)\}/', function ($m) use ($context) { + $value = data_get($context, $m[1]); + + return is_numeric($value) ? $value : (is_string($value) ? $value : '0'); + }, $expression); + + return $this->evaluateArithmetic($expression); + }, $html); + } + + /** + * Pass 3: Substitute all remaining scalar variables. + * + * Syntax: {namespace.property} or {scalar_key} + * Supports dot-notation for nested access. + */ + protected function substituteVariables(string $html, array $context): string + { + return preg_replace_callback('/\{([a-zA-Z_][a-zA-Z0-9_.]*)\}/', function (array $matches) use ($context) { + $path = $matches[1]; + $value = data_get($context, $path); + + if (is_array($value) || is_object($value)) { + return ''; // Skip non-scalar values + } + + return $value ?? ''; + }, $html); + } + + // ------------------------------------------------------------------------- + // Formula evaluation + // ------------------------------------------------------------------------- + + /** + * Safely evaluate a simple arithmetic expression string. + * + * Uses a recursive descent parser to avoid eval() and support: + * +, -, *, /, ^ (power), parentheses, and numeric literals. + * + * If mossadal/math-parser is available it will be used instead for + * full function support (sqrt, abs, round, etc.). + */ + protected function evaluateArithmetic(string $expression): string + { + // Prefer mossadal/math-parser if installed + if (class_exists(\MathParser\StdMathParser::class)) { + try { + $parser = new \MathParser\StdMathParser(); + $evaluator = new \MathParser\Interpreting\Evaluator(); + $ast = $parser->parse($expression); + $result = $ast->accept($evaluator); + + return is_float($result) ? rtrim(rtrim(number_format($result, 10, '.', ''), '0'), '.') : (string) $result; + } catch (\Throwable $e) { + return '#ERR'; + } + } + + // Fallback: simple recursive descent evaluator for +, -, *, /, () + try { + return (string) $this->parseExpression(trim($expression)); + } catch (\Throwable $e) { + return '#ERR'; + } + } + + /** + * Simple recursive descent arithmetic parser (fallback). + * Supports: +, -, *, /, unary minus, parentheses, integer and float literals. + */ + protected function parseExpression(string $expr): float + { + // Remove all whitespace + $expr = preg_replace('/\s+/', '', $expr); + $pos = 0; + + return $this->parseAddSub($expr, $pos); + } + + protected function parseAddSub(string $expr, int &$pos): float + { + $left = $this->parseMulDiv($expr, $pos); + + while ($pos < strlen($expr) && in_array($expr[$pos], ['+', '-'])) { + $op = $expr[$pos++]; + $right = $this->parseMulDiv($expr, $pos); + $left = $op === '+' ? $left + $right : $left - $right; + } + + return $left; + } + + protected function parseMulDiv(string $expr, int &$pos): float + { + $left = $this->parseUnary($expr, $pos); + + while ($pos < strlen($expr) && in_array($expr[$pos], ['*', '/'])) { + $op = $expr[$pos++]; + $right = $this->parseUnary($expr, $pos); + $left = $op === '*' ? $left * $right : ($right != 0 ? $left / $right : 0); + } + + return $left; + } + + protected function parseUnary(string $expr, int &$pos): float + { + if ($pos < strlen($expr) && $expr[$pos] === '-') { + $pos++; + + return -$this->parsePrimary($expr, $pos); + } + + return $this->parsePrimary($expr, $pos); + } + + protected function parsePrimary(string $expr, int &$pos): float + { + if ($pos < strlen($expr) && $expr[$pos] === '(') { + $pos++; // consume '(' + $value = $this->parseAddSub($expr, $pos); + if ($pos < strlen($expr) && $expr[$pos] === ')') { + $pos++; // consume ')' + } + + return $value; + } + + // Parse numeric literal + preg_match('/^-?[0-9]*\.?[0-9]+/', substr($expr, $pos), $m); + if (!empty($m)) { + $pos += strlen($m[0]); + + return (float) $m[0]; + } + + return 0; + } + + // ------------------------------------------------------------------------- + // HTML document wrapping + // ------------------------------------------------------------------------- + + /** + * Wrap the rendered element HTML in a full document shell with canvas dimensions. + */ + protected function wrapInDocument(string $bodyHtml, Template $template): string + { + $bgColor = $template->background_color ?? '#ffffff'; + $width = $template->width; + $height = $template->height; + $unit = $template->unit; + + return << + + + + + + +
+{$bodyHtml} +
+ + +HTML; + } + + // ------------------------------------------------------------------------- + // Utilities + // ------------------------------------------------------------------------- + + /** + * Convert a model to a plain array suitable for use as a variable context. + * Loads visible attributes and appended attributes. + */ + protected function modelToArray(Model $model): array + { + return $model->toArray(); + } + + /** + * Guess the context key (namespace) for a model instance from its class name. + * e.g. App\Models\Invoice → 'invoice' + */ + protected function guessContextKey(Model $model): string + { + return Str::snake(class_basename($model)); + } + + /** + * Convert an associative array of CSS properties to an inline style string. + */ + protected function buildInlineStyle(array $styles): string + { + $parts = []; + foreach ($styles as $property => $value) { + if ($value === null || $value === '') { + continue; + } + // Convert camelCase to kebab-case + $property = Str::kebab($property); + $parts[] = "{$property}: {$value}"; + } + + return implode('; ', $parts) . (count($parts) ? ';' : ''); + } +} diff --git a/src/routes.php b/src/routes.php index ce002e3d..b38fb0e4 100644 --- a/src/routes.php +++ b/src/routes.php @@ -315,6 +315,12 @@ function ($router, $controller) { $router->fleetbaseRoutes('schedule-templates'); $router->fleetbaseRoutes('schedule-availability'); $router->fleetbaseRoutes('schedule-constraints'); + $router->fleetbaseRoutes('templates', null, [], function ($router, $controller) { + $router->get('context-schemas', $controller('contextSchemas')); + $router->post('{id}/preview', $controller('preview')); + $router->post('{id}/render', $controller('render')); + }); + $router->fleetbaseRoutes('template-queries'); } ); } From c18e1c94a3c8afd20d71cfdfd616aa62bc8d13ef Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Tue, 3 Mar 2026 20:39:28 -0500 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20TemplateController=20=E2=80=94=20up?= =?UTF-8?q?sert=20nested=20queries=20on=20template=20create/update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Override createRecord and updateRecord to handle a 'queries' array in the request payload. After saving the template, _syncQueries() upserts each query (create if no UUID, update if UUID exists) and soft-deletes any queries not present in the incoming list (i.e. deleted in the builder). Temporary client-side UUIDs prefixed with '_new_' are treated as new records (UUID stripped before insert). The Template HTTP resource already serializes 'queries' via whenLoaded; both overridden methods eager-load 'queries' before returning the response so the frontend receives the full updated list in one round-trip. --- .../Internal/v1/TemplateController.php | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/src/Http/Controllers/Internal/v1/TemplateController.php b/src/Http/Controllers/Internal/v1/TemplateController.php index 4196182b..8ee1d0f7 100644 --- a/src/Http/Controllers/Internal/v1/TemplateController.php +++ b/src/Http/Controllers/Internal/v1/TemplateController.php @@ -4,11 +4,14 @@ use Fleetbase\Http\Controllers\FleetbaseController; use Fleetbase\Http\Requests\Internal\CreateTemplateRequest; +use Fleetbase\Http\Resources\Template as TemplateResource; use Fleetbase\Models\Template; +use Fleetbase\Models\TemplateQuery; use Fleetbase\Services\TemplateRenderService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Support\Str; class TemplateController extends FleetbaseController { @@ -33,6 +36,49 @@ public function __construct(TemplateRenderService $renderService) $this->renderService = $renderService; } + /** + * Create a template, then upsert any nested queries included in the payload. + * + * POST /templates + */ + public function createRecord(Request $request): JsonResponse + { + // Let the standard behaviour create the template record + $response = parent::createRecord($request); + + // Retrieve the newly-created template from the response resource + $template = $this->_templateFromResponse($response); + if ($template) { + $this->_syncQueries($template, $request->input('queries', [])); + // Re-load queries so the response includes them + $template->load('queries'); + TemplateResource::wrap('template'); + return new TemplateResource($template); + } + + return $response; + } + + /** + * Update a template, then upsert any nested queries included in the payload. + * + * PUT /templates/{id} + */ + public function updateRecord(Request $request, string $id): JsonResponse + { + $response = parent::updateRecord($request, $id); + + $template = $this->_templateFromResponse($response); + if ($template) { + $this->_syncQueries($template, $request->input('queries', [])); + $template->load('queries'); + TemplateResource::wrap('template'); + return new TemplateResource($template); + } + + return $response; + } + /** * Render a template to HTML for preview. * @@ -107,4 +153,109 @@ public function contextSchemas(): JsonResponse return response()->json(['schemas' => $schemas]); } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Upsert the nested queries array onto the given template. + * + * Each item in $queries may have: + * - uuid (string|null) — present for existing queries, absent for new ones + * - All other TemplateQuery fillable fields + * + * Strategy: + * 1. Collect the UUIDs present in the incoming payload. + * 2. Soft-delete any existing queries NOT in that set (removed by the user). + * 3. For each incoming query: update if UUID exists, create if not. + * + * @param Template $template + * @param array $queries + */ + protected function _syncQueries(Template $template, array $queries): void + { + if (empty($queries) && !is_array($queries)) { + return; + } + + $companyUuid = session('company'); + $createdByUuid = session('user'); + $incomingUuids = []; + + foreach ($queries as $queryData) { + $uuid = data_get($queryData, 'uuid'); + + // Skip client-side temporary IDs (prefixed with _unsaved_) + if ($uuid && Str::startsWith($uuid, '_unsaved_')) { + $uuid = null; + } + + if ($uuid) { + // Update existing query + $existing = TemplateQuery::where('uuid', $uuid) + ->where('template_uuid', $template->uuid) + ->first(); + + if ($existing) { + $existing->fill([ + 'label' => data_get($queryData, 'label'), + 'variable_name' => data_get($queryData, 'variable_name'), + 'description' => data_get($queryData, 'description'), + 'model_type' => data_get($queryData, 'model_type'), + 'conditions' => data_get($queryData, 'conditions', []), + 'sort' => data_get($queryData, 'sort', []), + 'limit' => data_get($queryData, 'limit'), + 'with' => data_get($queryData, 'with', []), + ])->save(); + + $incomingUuids[] = $uuid; + } + } else { + // Create new query + $newQuery = TemplateQuery::create([ + 'template_uuid' => $template->uuid, + 'company_uuid' => $companyUuid, + 'created_by_uuid'=> $createdByUuid, + 'label' => data_get($queryData, 'label'), + 'variable_name' => data_get($queryData, 'variable_name'), + 'description' => data_get($queryData, 'description'), + 'model_type' => data_get($queryData, 'model_type'), + 'conditions' => data_get($queryData, 'conditions', []), + 'sort' => data_get($queryData, 'sort', []), + 'limit' => data_get($queryData, 'limit'), + 'with' => data_get($queryData, 'with', []), + ]); + + $incomingUuids[] = $newQuery->uuid; + } + } + + // Remove queries that were deleted in the builder + // (only when the caller explicitly sent a queries array — even if empty) + $template->queries() + ->whereNotIn('uuid', $incomingUuids) + ->delete(); + } + + /** + * Extract the Template model from a JsonResponse that wraps a TemplateResource. + */ + protected function _templateFromResponse($response): ?Template + { + if ($response instanceof TemplateResource) { + return $response->resource; + } + + // The parent returns a TemplateResource directly (not wrapped in JsonResponse) + // when it's an internal request. Try to get the underlying model. + if (method_exists($response, 'resource') || property_exists($response, 'resource')) { + $model = $response->resource ?? null; + if ($model instanceof Template) { + return $model; + } + } + + return null; + } } From 682d010c96ef01c3dc23abc8fecc6cd552b76f79 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Wed, 4 Mar 2026 03:38:52 -0500 Subject: [PATCH 3/9] fix: correct return type on createRecord() and updateRecord() in TemplateController Both methods were declared as returning JsonResponse but actually return a TemplateResource (which extends JsonResource, not JsonResponse). This caused a fatal TypeError at runtime: Return value must be of type Illuminate\Http\JsonResponse, Fleetbase\Http\Resources\Template returned Fix: widen the return type union to JsonResource|JsonResponse so both the TemplateResource path (normal request) and the JsonResponse path (error / internal request fallback) satisfy the type contract. --- src/Http/Controllers/Internal/v1/TemplateController.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/TemplateController.php b/src/Http/Controllers/Internal/v1/TemplateController.php index 8ee1d0f7..4106e7b2 100644 --- a/src/Http/Controllers/Internal/v1/TemplateController.php +++ b/src/Http/Controllers/Internal/v1/TemplateController.php @@ -10,6 +10,7 @@ use Fleetbase\Services\TemplateRenderService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Response; use Illuminate\Support\Str; @@ -41,7 +42,7 @@ public function __construct(TemplateRenderService $renderService) * * POST /templates */ - public function createRecord(Request $request): JsonResponse + public function createRecord(Request $request): JsonResource|JsonResponse { // Let the standard behaviour create the template record $response = parent::createRecord($request); @@ -64,7 +65,7 @@ public function createRecord(Request $request): JsonResponse * * PUT /templates/{id} */ - public function updateRecord(Request $request, string $id): JsonResponse + public function updateRecord(Request $request, string $id): JsonResource|JsonResponse { $response = parent::updateRecord($request, $id); From 2224628860eced95fc8ac4fbed179cf106bdb580 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Wed, 4 Mar 2026 03:43:28 -0500 Subject: [PATCH 4/9] refactor: use onAfterCreate/onAfterUpdate hooks instead of overriding createRecord/updateRecord MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the manual overrides of createRecord() and updateRecord() with the proper HasApiControllerBehavior hook methods onAfterCreate() and onAfterUpdate(). getControllerCallback() in the trait checks whether these methods exist on the controller and wraps them as closures that are passed to createRecordFromRequest() and updateRecordFromRequest() in HasApiModelBehavior. Both hooks receive the same signature: (Request $request, Model $record, array $input). The hooks call _syncQueries() to upsert/delete TemplateQuery rows and then load the queries relationship so the response includes them — identical behaviour to before, but without duplicating the entire create/update flow or introducing a return-type mismatch. The _templateFromResponse() helper is also removed as it is no longer needed. --- .../Internal/v1/TemplateController.php | 116 +++++++----------- 1 file changed, 46 insertions(+), 70 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/TemplateController.php b/src/Http/Controllers/Internal/v1/TemplateController.php index 4106e7b2..d7e0516f 100644 --- a/src/Http/Controllers/Internal/v1/TemplateController.php +++ b/src/Http/Controllers/Internal/v1/TemplateController.php @@ -4,13 +4,11 @@ use Fleetbase\Http\Controllers\FleetbaseController; use Fleetbase\Http\Requests\Internal\CreateTemplateRequest; -use Fleetbase\Http\Resources\Template as TemplateResource; use Fleetbase\Models\Template; use Fleetbase\Models\TemplateQuery; use Fleetbase\Services\TemplateRenderService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Response; use Illuminate\Support\Str; @@ -37,49 +35,48 @@ public function __construct(TemplateRenderService $renderService) $this->renderService = $renderService; } + // ------------------------------------------------------------------------- + // Lifecycle hooks — called automatically by HasApiControllerBehavior + // ------------------------------------------------------------------------- + /** - * Create a template, then upsert any nested queries included in the payload. + * Called by HasApiControllerBehavior::createRecord() after the template + * record has been persisted. Syncs the nested queries array included in + * the request payload. * - * POST /templates + * Signature expected by getControllerCallback(): ($request, $record, $input) + * + * @param Request $request + * @param Template $record + * @param array $input */ - public function createRecord(Request $request): JsonResource|JsonResponse + public function onAfterCreate(Request $request, Template $record, array $input): void { - // Let the standard behaviour create the template record - $response = parent::createRecord($request); - - // Retrieve the newly-created template from the response resource - $template = $this->_templateFromResponse($response); - if ($template) { - $this->_syncQueries($template, $request->input('queries', [])); - // Re-load queries so the response includes them - $template->load('queries'); - TemplateResource::wrap('template'); - return new TemplateResource($template); - } - - return $response; + $this->_syncQueries($record, $request->input('queries', [])); + $record->load('queries'); } /** - * Update a template, then upsert any nested queries included in the payload. + * Called by HasApiControllerBehavior::updateRecord() after the template + * record has been updated. Syncs the nested queries array included in + * the request payload. * - * PUT /templates/{id} + * Signature expected by getControllerCallback(): ($request, $record, $input) + * + * @param Request $request + * @param Template $record + * @param array $input */ - public function updateRecord(Request $request, string $id): JsonResource|JsonResponse + public function onAfterUpdate(Request $request, Template $record, array $input): void { - $response = parent::updateRecord($request, $id); - - $template = $this->_templateFromResponse($response); - if ($template) { - $this->_syncQueries($template, $request->input('queries', [])); - $template->load('queries'); - TemplateResource::wrap('template'); - return new TemplateResource($template); - } - - return $response; + $this->_syncQueries($record, $request->input('queries', [])); + $record->load('queries'); } + // ------------------------------------------------------------------------- + // Custom endpoints + // ------------------------------------------------------------------------- + /** * Render a template to HTML for preview. * @@ -180,15 +177,15 @@ protected function _syncQueries(Template $template, array $queries): void return; } - $companyUuid = session('company'); - $createdByUuid = session('user'); - $incomingUuids = []; + $companyUuid = session('company'); + $createdByUuid = session('user'); + $incomingUuids = []; foreach ($queries as $queryData) { $uuid = data_get($queryData, 'uuid'); - // Skip client-side temporary IDs (prefixed with _unsaved_) - if ($uuid && Str::startsWith($uuid, '_unsaved_')) { + // Skip client-side temporary IDs (prefixed with _new_ or _unsaved_) + if ($uuid && (Str::startsWith($uuid, '_new_') || Str::startsWith($uuid, '_unsaved_'))) { $uuid = null; } @@ -215,17 +212,17 @@ protected function _syncQueries(Template $template, array $queries): void } else { // Create new query $newQuery = TemplateQuery::create([ - 'template_uuid' => $template->uuid, - 'company_uuid' => $companyUuid, - 'created_by_uuid'=> $createdByUuid, - 'label' => data_get($queryData, 'label'), - 'variable_name' => data_get($queryData, 'variable_name'), - 'description' => data_get($queryData, 'description'), - 'model_type' => data_get($queryData, 'model_type'), - 'conditions' => data_get($queryData, 'conditions', []), - 'sort' => data_get($queryData, 'sort', []), - 'limit' => data_get($queryData, 'limit'), - 'with' => data_get($queryData, 'with', []), + 'template_uuid' => $template->uuid, + 'company_uuid' => $companyUuid, + 'created_by_uuid' => $createdByUuid, + 'label' => data_get($queryData, 'label'), + 'variable_name' => data_get($queryData, 'variable_name'), + 'description' => data_get($queryData, 'description'), + 'model_type' => data_get($queryData, 'model_type'), + 'conditions' => data_get($queryData, 'conditions', []), + 'sort' => data_get($queryData, 'sort', []), + 'limit' => data_get($queryData, 'limit'), + 'with' => data_get($queryData, 'with', []), ]); $incomingUuids[] = $newQuery->uuid; @@ -238,25 +235,4 @@ protected function _syncQueries(Template $template, array $queries): void ->whereNotIn('uuid', $incomingUuids) ->delete(); } - - /** - * Extract the Template model from a JsonResponse that wraps a TemplateResource. - */ - protected function _templateFromResponse($response): ?Template - { - if ($response instanceof TemplateResource) { - return $response->resource; - } - - // The parent returns a TemplateResource directly (not wrapped in JsonResponse) - // when it's an internal request. Try to get the underlying model. - if (method_exists($response, 'resource') || property_exists($response, 'resource')) { - $model = $response->resource ?? null; - if ($model instanceof Template) { - return $model; - } - } - - return null; - } } From 4e49bc98d26b42d9d2b2ec5b92f62a9343fa11fc Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Wed, 4 Mar 2026 04:35:20 -0500 Subject: [PATCH 5/9] feat: add POST /templates/preview endpoint for unsaved template payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously only POST /templates/{id}/preview existed, which required a persisted record. This meant the builder could not preview a template before it was saved for the first time. Changes: routes.php Register POST 'preview' (no {id}) before the existing {id}/preview route so Laravel's router matches the literal segment first. TemplateController::previewUnsaved() New method that: 1. Reads the 'template' key from the request body. 2. Hydrates a transient (non-persisted) Template model via fill(). 3. Hydrates transient TemplateQuery models from the nested 'queries' array and sets them directly on the relation via setRelation() so the render pipeline can iterate them without any DB access. 4. Resolves an optional subject model (subject_type / subject_id). 5. Delegates to TemplateRenderService::renderToHtml() and returns { html } — identical response shape to the persisted preview. TemplateRenderService::buildContext() Guard the loadMissing('queries') call with a relationLoaded() check. When the relation has already been set in-memory (transient model case) loadMissing would overwrite it with an empty DB result, breaking the query context tier for unsaved previews. --- .../Internal/v1/TemplateController.php | 68 +++++++++++++++++++ src/Services/TemplateRenderService.php | 6 +- src/routes.php | 1 + 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/Http/Controllers/Internal/v1/TemplateController.php b/src/Http/Controllers/Internal/v1/TemplateController.php index d7e0516f..9f962c08 100644 --- a/src/Http/Controllers/Internal/v1/TemplateController.php +++ b/src/Http/Controllers/Internal/v1/TemplateController.php @@ -77,6 +77,74 @@ public function onAfterUpdate(Request $request, Template $record, array $input): // Custom endpoints // ------------------------------------------------------------------------- + /** + * Render an unsaved template payload to HTML for preview. + * + * POST /templates/preview (no {id} — template has not been persisted yet) + * + * Body: + * template (object) — the full template payload from the builder + * .name, .content, .context_type, .width, .height, .unit, + * .orientation, .margins, .styles, .queries (array, optional) + * subject_type (string, optional) — fully-qualified model class + * subject_id (string, optional) — UUID or public_id of the subject record + */ + public function previewUnsaved(Request $request): JsonResponse + { + $payload = $request->input('template', []); + + // Hydrate a transient (non-persisted) Template model from the payload. + // fill() respects $fillable so unknown keys are silently ignored. + $template = new Template(); + $template->fill([ + 'name' => data_get($payload, 'name', 'Preview'), + 'content' => data_get($payload, 'content', []), + 'context_type' => data_get($payload, 'context_type', 'generic'), + 'width' => data_get($payload, 'width'), + 'height' => data_get($payload, 'height'), + 'unit' => data_get($payload, 'unit', 'mm'), + 'orientation' => data_get($payload, 'orientation', 'portrait'), + 'margins' => data_get($payload, 'margins', []), + 'styles' => data_get($payload, 'styles', []), + ]); + + // Hydrate transient TemplateQuery objects so the render pipeline can + // execute them without any DB records existing yet. + $rawQueries = data_get($payload, 'queries', []); + $queryModels = collect($rawQueries)->map(function ($q) { + $tq = new TemplateQuery(); + $tq->fill([ + 'label' => data_get($q, 'label'), + 'variable_name' => data_get($q, 'variable_name'), + 'model_type' => data_get($q, 'model_type'), + 'conditions' => data_get($q, 'conditions', []), + 'sort' => data_get($q, 'sort', []), + 'limit' => data_get($q, 'limit'), + 'with' => data_get($q, 'with', []), + ]); + + return $tq; + }); + + // Set the queries relation directly so buildContext() can iterate them + // without calling loadMissing() against the database. + $template->setRelation('queries', $queryModels); + + $subjectType = $request->input('subject_type'); + $subjectId = $request->input('subject_id'); + $subject = null; + + if ($subjectType && $subjectId && class_exists($subjectType)) { + $subject = $subjectType::where('uuid', $subjectId) + ->orWhere('public_id', $subjectId) + ->first(); + } + + $html = $this->renderService->renderToHtml($template, $subject); + + return response()->json(['html' => $html]); + } + /** * Render a template to HTML for preview. * diff --git a/src/Services/TemplateRenderService.php b/src/Services/TemplateRenderService.php index d92d5660..bd1bec62 100644 --- a/src/Services/TemplateRenderService.php +++ b/src/Services/TemplateRenderService.php @@ -137,7 +137,11 @@ protected function buildContext(Template $template, ?Model $subject = null): arr } // Tier 3: Query-based collections - $template->loadMissing('queries'); + // Skip loadMissing when the relation has already been set in-memory + // (e.g. for a transient/unsaved template hydrated from a request payload). + if (!$template->relationLoaded('queries')) { + $template->loadMissing('queries'); + } foreach ($template->queries as $templateQuery) { $results = $templateQuery->execute(); $context[$templateQuery->variable_name] = $results->map(fn ($item) => $this->modelToArray($item))->toArray(); diff --git a/src/routes.php b/src/routes.php index b38fb0e4..35e650dd 100644 --- a/src/routes.php +++ b/src/routes.php @@ -317,6 +317,7 @@ function ($router, $controller) { $router->fleetbaseRoutes('schedule-constraints'); $router->fleetbaseRoutes('templates', null, [], function ($router, $controller) { $router->get('context-schemas', $controller('contextSchemas')); + $router->post('preview', $controller('previewUnsaved')); $router->post('{id}/preview', $controller('preview')); $router->post('{id}/render', $controller('render')); }); From 24ad29f5489e48e3fc9a1941e5c8f2cb76b40674 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 4 Mar 2026 17:36:08 +0800 Subject: [PATCH 6/9] fix fleetbaseRoute defintiion for templates --- src/routes.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes.php b/src/routes.php index b38fb0e4..37650740 100644 --- a/src/routes.php +++ b/src/routes.php @@ -315,7 +315,7 @@ function ($router, $controller) { $router->fleetbaseRoutes('schedule-templates'); $router->fleetbaseRoutes('schedule-availability'); $router->fleetbaseRoutes('schedule-constraints'); - $router->fleetbaseRoutes('templates', null, [], function ($router, $controller) { + $router->fleetbaseRoutes('templates', function ($router, $controller) { $router->get('context-schemas', $controller('contextSchemas')); $router->post('{id}/preview', $controller('preview')); $router->post('{id}/render', $controller('render')); From 7c290351ed9ec36e27a0aa6ed344288e2a6668ba Mon Sep 17 00:00:00 2001 From: Fleetbase Dev Date: Tue, 10 Mar 2026 02:55:45 -0400 Subject: [PATCH 7/9] fix: support dot-notation paths in processIterationBlocks (e.g. invoice.items) --- src/Services/TemplateRenderService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Services/TemplateRenderService.php b/src/Services/TemplateRenderService.php index bd1bec62..7cf8d3fd 100644 --- a/src/Services/TemplateRenderService.php +++ b/src/Services/TemplateRenderService.php @@ -373,7 +373,7 @@ protected function renderTableElement(array $element, string $styleStr): string */ protected function processIterationBlocks(string $html, array $context): string { - $pattern = '/\{\{#each\s+(\w+)\}\}(.*?)\{\{\/each\}\}/s'; + $pattern = '/\{\{#each\s+([\w.]+)\}\}(.*?)\{\{\/each\}\}/s'; return preg_replace_callback($pattern, function (array $matches) use ($context) { $variableName = $matches[1]; From a9a6563353e349094a6ccb6f6c879a5becbcaad3 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 14 Mar 2026 17:03:31 +0800 Subject: [PATCH 8/9] add bulk delete route for templates --- src/routes.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes.php b/src/routes.php index 9c00e156..50b91a1a 100644 --- a/src/routes.php +++ b/src/routes.php @@ -318,6 +318,7 @@ function ($router, $controller) { $router->fleetbaseRoutes('templates', function ($router, $controller) { $router->get('context-schemas', $controller('contextSchemas')); $router->post('preview', $controller('previewUnsaved')); + $router->delete('bulk-delete', $controller('bulkDelete')); $router->post('{id}/preview', $controller('preview')); $router->post('{id}/render', $controller('render')); }); From 088793e29ed718fed7f40803179853da50246460 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 14 Mar 2026 17:09:46 +0800 Subject: [PATCH 9/9] fix dependency versions --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 84aaec16..60a787d4 100644 --- a/composer.json +++ b/composer.json @@ -56,8 +56,8 @@ "spatie/laravel-sluggable": "^3.5", "sqids/sqids": "^0.4.1", "xantios/mimey": "^2.2.0", - "spatie/laravel-pdf": "^2.0", - "mossadal/math-parser": "^2.0" + "spatie/laravel-pdf": "^1.9", + "mossadal/math-parser": "^1.3" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.34.1",