Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
189b15e
Add SmartTwin event subscriber skeleton
pvankouteren May 19, 2026
9d0496e
Propagate user deletion to SmartTwin via UserDeleted event
pvankouteren May 20, 2026
c9e5dfc
Add SmartTwin API client with create/delete user resources
pvankouteren May 21, 2026
a91f225
Wire SmartTwin out-jobs to the API client
pvankouteren May 21, 2026
526c9c2
Merge branch 'develop' into smart-twin-skeleton
pvankouteren Jun 10, 2026
72a8c0f
Merge branch 'develop' into smart-twin-skeleton
pvankouteren Jun 16, 2026
9561dde
Add SmartTwin webhook endpoint, key rotation, and subscribe/unsubscri…
pvankouteren Jun 16, 2026
fab66de
Added migration, command and job primer
pvankouteren Jun 17, 2026
8284e51
Fix Client::decode() TypeError when API returns non-array JSON
pvankouteren Jun 19, 2026
a6f9042
Merge remote-tracking branch 'origin/smart-twin-skeleton' into smart-…
pvankouteren Jun 19, 2026
734b1e4
Fall back to Value key when subscriptionId missing from subscribe res…
pvankouteren Jun 19, 2026
08ddda3
Move SmartTwinSigned to Api middleware namespace and add EventType enum
pvankouteren Jun 19, 2026
7dc0dd1
Store SmartTwin webhook callback on building via DossierId lookup
pvankouteren Jun 19, 2026
3cb2a02
Add ShouldBeUnique to GetAdviceResults job, keyed on EventType + buil…
pvankouteren Jun 19, 2026
ed49070
Implement GetAdviceResults job with API calls and SmartTwinService stub
pvankouteren Jun 19, 2026
6814db6
Schedule GetAdviceResults cron at 03:00 and add optional buildingId a…
pvankouteren Jun 19, 2026
d00cd1b
Fix PHPStan errors and apply phpcbf formatting
pvankouteren Jun 19, 2026
cc4c876
Apply phpcbf formatting to GetAdviceResults command
pvankouteren Jun 19, 2026
760837a
Move SmartTwin callback dispatch from observer to event subscriber
pvankouteren Jun 30, 2026
be946fa
Merge branch 'develop' into smart-twin-skeleton
pvankouteren Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ SENTRY_LARAVEL_DSN=
# Econobis API settings
ECONOBIS_ENABLED=false

# SmartTwin API settings
SMARTTWIN_ENABLED=false
SMARTTWIN_BASE_URI=https://hoomdossier.test.smarttwin.nl
SMARTTWIN_KEY=
SMARTTWIN_DEBUG=false
# Key SmartTwin uses in X-Webhook-ApiKey header when calling back our webhook
SMARTTWIN_API_SIGN_KEY=
# Previous sign key(s) for zero-downtime key rotation, comma-separated (optional)
SMARTTWIN_PREVIOUS_API_SIGN_KEY=
# Subscription ID returned by api:smarttwin:subscribe
SMARTTWIN_SUBSCRIPTION_ID=

# Mail adresses
ADMIN_MAIL_ADDRESS='[email protected],[email protected]'
HOOM_CONTACT_EMAIL_WHITELIST='[email protected],[email protected]'
Expand Down
44 changes: 44 additions & 0 deletions app/Console/Commands/Api/SmartTwin/GetAdviceResults.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

namespace App\Console\Commands\Api\SmartTwin;

use App\Models\Building;
use Illuminate\Console\Command;
use App\Jobs\SmartTwin\Out\GetAdviceResults as GetAdviceResultsJob;

class GetAdviceResults extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'api:smarttwin:get-advice-results
{buildingId? : Only process this specific building ID}';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Fallback cron: get SmartTwin advice results not yet fetched by a job';

/**
* Execute the console command.
*/
public function handle()
{
Building::containsPendingSmartTwinAdvices()
->when($this->argument('buildingId'), fn($q, $id) => $q->where('id', $id))
->chunk(5, function ($buildings) {
/** @var Building $building */
foreach ($buildings as $building) {
$this->line("Fallback cron to get advice results for " . $building->getKey());

foreach ($building->getSmartTwinCallbacks() as $callback) {
GetAdviceResultsJob::dispatchSync($callback, $building->getKey());
}
}
});
}
}
48 changes: 48 additions & 0 deletions app/Console/Commands/Api/SmartTwin/SubscribeCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace App\Console\Commands\Api\SmartTwin;

use App\Services\SmartTwin\Api\SmartTwinApi;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;

class SubscribeCommand extends Command
{
protected $signature = 'api:smarttwin:subscribe
{--name= : Subscriber name (defaults to app name)}';

protected $description = 'Register an event subscription with the SmartTwin API. Outputs the subscriptionId to store in SMARTTWIN_SUBSCRIPTION_ID.';

public function handle(SmartTwinApi $api): int
{
$subscriberName = $this->option('name') ?? config('app.name');
$callbackUrl = route('api.v1.smarttwin.store');
$signKey = config('hoomdossier.services.smarttwin.sign-key', '') ?: null;

$this->info('Subscribing to SmartTwin events...');
$this->line(" Subscriber : {$subscriberName}");
$this->line(" Callback : {$callbackUrl}");

$response = $api->events()->subscribe($subscriberName, $callbackUrl, $signKey);

$subscriptionId = $response['subscriptionId'] ?? $response['Value'] ?? null;

if (! $subscriptionId) {
$this->error('Subscription failed: no subscriptionId in response.');
return self::FAILURE;
}

Log::info('SmartTwin event subscription created', [
'subscriptionId' => $subscriptionId,
'subscriberName' => $subscriberName,
'callbackUrl' => $callbackUrl,
]);

$this->info('Subscribed successfully!');
$this->newLine();
$this->comment('Add the following to your .env file:');
$this->line("SMARTTWIN_SUBSCRIPTION_ID={$subscriptionId}");

return self::SUCCESS;
}
}
33 changes: 33 additions & 0 deletions app/Console/Commands/Api/SmartTwin/UnsubscribeCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace App\Console\Commands\Api\SmartTwin;

use App\Services\SmartTwin\Api\SmartTwinApi;
use Illuminate\Console\Command;

class UnsubscribeCommand extends Command
{
protected $signature = 'api:smarttwin:unsubscribe
{subscriptionId? : The subscription ID to cancel (defaults to SMARTTWIN_SUBSCRIPTION_ID)}';

protected $description = 'Cancel an event subscription with the SmartTwin API.';

public function handle(SmartTwinApi $api): int
{
$subscriptionId = $this->argument('subscriptionId')
?? config('hoomdossier.services.smarttwin.subscription-id');

if (! $subscriptionId) {
$this->error('No subscriptionId provided. Pass it as argument or set SMARTTWIN_SUBSCRIPTION_ID in .env.');
return self::FAILURE;
}

$this->info("Unsubscribing from SmartTwin events (ID: {$subscriptionId})...");

$api->events()->unsubscribe($subscriptionId);

$this->info('Unsubscribed successfully.');

return self::SUCCESS;
}
}
9 changes: 9 additions & 0 deletions app/Enums/SmartTwin/EventType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace App\Enums\SmartTwin;

enum EventType: string
{
case RESIDENT_SCAN_FINISHED = 'smarttwin.quickscan.finalized';
case COACH_SCAN_FINISHED = 'smarttwin.advice.finalized';
}
16 changes: 16 additions & 0 deletions app/Events/AccountVerified.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace App\Events;

use App\Models\Account;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class AccountVerified
{
use Dispatchable, SerializesModels;

public function __construct(public Account $account)
{
}
}
30 changes: 30 additions & 0 deletions app/Events/SmartTwinCallbackReceived.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace App\Events;

use App\Models\Building;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class SmartTwinCallbackReceived
{
use Dispatchable, SerializesModels;

public Building $building;

/**
* The callback entries that were newly added to the building.
*
* @var array<int, mixed>
*/
public array $addedCallbacks;

/**
* @param array<int, mixed> $addedCallbacks
*/
public function __construct(Building $building, array $addedCallbacks)
{
$this->building = $building;
$this->addedCallbacks = $addedCallbacks;
}
}
7 changes: 1 addition & 6 deletions app/Events/UserDeleted.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,7 @@ class UserDeleted
{
use Dispatchable, InteractsWithSockets, SerializesModels;

/**
* Create a new job instance.
*
* @return void
*/
public function __construct(public array $cooperation, public array $accountRelated)
public function __construct(public array $cooperation, public array $context)
{
}

Expand Down
5 changes: 5 additions & 0 deletions app/Helpers/Hoomdossier.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ public static function hasEnabledEconobisCalls(): bool
return config('hoomdossier.services.econobis.enabled', false);
}

public static function hasEnabledSmartTwinCalls(): bool
{
return config('hoomdossier.services.smarttwin.enabled', false);
}

/**
* @deprecated
* Return the most credible value from a given collection.
Expand Down
1 change: 1 addition & 0 deletions app/Helpers/Models/BuildingSettingHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class BuildingSettingHelper
const SHORT_SMALL_MEASURES_ENABLED_LITE_SCAN = 'small_measures_enabled_lite-scan';
const SHORT_SCAN_ENABLED_QUICK_SCAN = 'scan_enabled_quick-scan';
const SHORT_SCAN_ENABLED_LITE_SCAN = 'scan_enabled_lite-scan';
const SHORT_SMARTTWIN_DOSSIER_ID = 'smarttwin_dossier_id';

public static function getAvailableSettings(): array
{
Expand Down
57 changes: 57 additions & 0 deletions app/Http/Controllers/Api/V1/SmartTwinController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

namespace App\Http\Controllers\Api\V1;

use App\Helpers\Models\BuildingSettingHelper;
use App\Models\Building;
use App\Models\BuildingSetting;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;

class SmartTwinController
{
public function store(Request $request): Response
{
$payload = $request->json()->all();

Log::debug('SmartTwin webhook received', $payload);

$data = $payload['data'] ?? null;

if (! is_array($data)) {
Log::warning('SmartTwin webhook missing data key', $payload);
return response()->noContent();
}

$dossierId = $data['DossierId'] ?? null;

if (! $dossierId) {
Log::warning('SmartTwin webhook missing DossierId', $data);
return response()->noContent();
}

$building = BuildingSetting::forShort(BuildingSettingHelper::SHORT_SMARTTWIN_DOSSIER_ID)
->where('value', $dossierId)
->first()
?->building;

if (! $building instanceof Building) {
Log::warning('SmartTwin webhook: no building found for DossierId', ['dossierId' => $dossierId]);
return response()->noContent();
}

$callbacks = $building->getSmartTwinCallbacks();
$callbacks[] = $data;

$building->smarttwin_callback = $callbacks;
$building->save();

Log::debug('SmartTwin webhook stored callback for building', [
'building_id' => $building->getKey(),
'dossierId' => $dossierId,
]);

return response()->noContent();
}
}
29 changes: 29 additions & 0 deletions app/Http/Middleware/Api/SmartTwinSigned.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace App\Http\Middleware\Api;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class SmartTwinSigned
{
public function handle(Request $request, Closure $next): Response
{
$header = $request->header('X-Webhook-ApiKey', '');
$signKey = config('hoomdossier.services.smarttwin.sign-key', '');
$previousSignKey = config('hoomdossier.services.smarttwin.previous-sign-key', '');

$previousKeys = $previousSignKey !== ''
? array_map('trim', explode(',', $previousSignKey))
: [];

$validKeys = array_filter([$signKey, ...$previousKeys], fn($k) => $k !== '');

if ($header !== '' && in_array($header, $validKeys, true)) {
return $next($request);
}

return response()->json(['error' => 'Unauthorized'], 401);
}
}
54 changes: 54 additions & 0 deletions app/Jobs/SmartTwin/Out/CreateCoachAccount.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace App\Jobs\SmartTwin\Out;

use App\Helpers\Hoomdossier;
use App\Helpers\Queue;
use App\Models\User;
use App\Services\SmartTwin\Api\SmartTwinApi;
use App\Services\SmartTwin\Api\UserRole;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

class CreateCoachAccount implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public int $tries = 3;

public function __construct(public User $user)
{
$this->queue = Queue::APP_EXTERNAL;
}

public function handle(SmartTwinApi $api): void
{
if (! Hoomdossier::hasEnabledSmartTwinCalls()) {
Log::debug('SmartTwin calls are disabled, skipping CreateCoachAccount for user ' . $this->user->id);
return;
}

if (! empty($this->user->extra['smarttwin_user_id'] ?? null)) {
return;
}

$response = $api->user()->create(
$this->user->account->email,
$this->user->first_name ?? '',
$this->user->last_name ?? '',
UserRole::Advisor,
);

$userId = $response['userId'] ?? null;
if (! empty($userId)) {
$extra = $this->user->extra ?? [];
$extra['smarttwin_user_id'] = $userId;
$this->user->extra = $extra;
$this->user->save();
}
}
}
Loading
Loading