diff --git a/composer.json b/composer.json index 1efb0e0..60a787d 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": "^1.9", + "mossadal/math-parser": "^1.3" }, "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 0000000..e922de6 --- /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 0000000..9ad6106 --- /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 0000000..9f962c0 --- /dev/null +++ b/src/Http/Controllers/Internal/v1/TemplateController.php @@ -0,0 +1,306 @@ +renderService = $renderService; + } + + // ------------------------------------------------------------------------- + // Lifecycle hooks — called automatically by HasApiControllerBehavior + // ------------------------------------------------------------------------- + + /** + * Called by HasApiControllerBehavior::createRecord() after the template + * record has been persisted. Syncs the nested queries array included in + * the request payload. + * + * Signature expected by getControllerCallback(): ($request, $record, $input) + * + * @param Request $request + * @param Template $record + * @param array $input + */ + public function onAfterCreate(Request $request, Template $record, array $input): void + { + $this->_syncQueries($record, $request->input('queries', [])); + $record->load('queries'); + } + + /** + * Called by HasApiControllerBehavior::updateRecord() after the template + * record has been updated. Syncs the nested queries array included in + * the request payload. + * + * Signature expected by getControllerCallback(): ($request, $record, $input) + * + * @param Request $request + * @param Template $record + * @param array $input + */ + public function onAfterUpdate(Request $request, Template $record, array $input): void + { + $this->_syncQueries($record, $request->input('queries', [])); + $record->load('queries'); + } + + // ------------------------------------------------------------------------- + // 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. + * + * 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]); + } + + // ------------------------------------------------------------------------- + // 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 _new_ or _unsaved_) + if ($uuid && (Str::startsWith($uuid, '_new_') || 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(); + } +} diff --git a/src/Http/Controllers/Internal/v1/TemplateQueryController.php b/src/Http/Controllers/Internal/v1/TemplateQueryController.php new file mode 100644 index 0000000..dc1c246 --- /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 0000000..1ae0747 --- /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 0000000..a0d13fd --- /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 0000000..97481cf --- /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 0000000..fdbc8d8 --- /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 817da67..9e3ed4b 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 0000000..7cf8d3f --- /dev/null +++ b/src/Services/TemplateRenderService.php @@ -0,0 +1,646 @@ + [ + '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 + // 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(); + } + + 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 ce002e3..50b91a1 100644 --- a/src/routes.php +++ b/src/routes.php @@ -315,6 +315,14 @@ function ($router, $controller) { $router->fleetbaseRoutes('schedule-templates'); $router->fleetbaseRoutes('schedule-availability'); $router->fleetbaseRoutes('schedule-constraints'); + $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')); + }); + $router->fleetbaseRoutes('template-queries'); } ); }