Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 15 additions & 4 deletions lib/Service/OriPublicationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -157,14 +157,25 @@ public function publish(string $votingRoundId): void
throw new \RuntimeException('JSON encoding of ORI payload failed: '.json_last_error_msg());
}

// #320: Attach the configurable ORI bearer token when set.
// The secret is stored in IAppConfig under key 'ori_bearer_secret'.
// When not configured the Authorization header is omitted (ORI endpoints
// that require auth will return 401, surfacing a configuration gap).
$headers = [
'Content-Type' => 'application/ld+json',
'Accept' => 'application/json',
];

$bearerSecret = $this->appConfig->getValueString(Application::APP_ID, 'ori_bearer_secret', '');
if ($bearerSecret !== '') {
$headers['Authorization'] = 'Bearer '.$bearerSecret;
}

$client = $this->clientService->newClient();
$client->post(
$endpoint,
[
'headers' => [
'Content-Type' => 'application/ld+json',
'Accept' => 'application/json',
],
'headers' => $headers,
'body' => $body,
'timeout' => 10,
]
Expand Down
1 change: 1 addition & 0 deletions lib/Service/SettingsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class SettingsService
private const CONFIG_KEYS = [
'register',
'ori_endpoint',
'ori_bearer_secret',
'email_voting_enabled',
'minutesSchema',
'decisionSchema',
Expand Down
62 changes: 47 additions & 15 deletions lib/Service/VotingService.php
Original file line number Diff line number Diff line change
Expand Up @@ -513,20 +513,26 @@ public function closeVotingRound(string $votingRoundId, bool $anonymise=false):
$result = ($tally['result'] ?? 'invalid');

// Transition motion lifecycle based on result.
// #318: Re-throw \InvalidArgumentException (bad state-machine transition) so the
// caller learns the round was closed but motion lifecycle could not be updated.
// Transient/infrastructure errors are still logged-and-continued so a network hiccup
// does not leave the round un-closed; however they are logged at ERROR level so they
// surface in monitoring rather than silently disappearing.
$lifecycleError = null;
if ($round !== null) {
foreach (($round['relations'] ?? []) as $rel) {
if (($rel['schema'] ?? '') === 'motion') {
$motionId = ($rel['id'] ?? null);
if ($motionId !== null) {
try {
// Only transition to defined terminal states via the guarded state machine.
$motionLifecycle = match ($result) {
'adopted' => 'adopted',
'rejected' => 'rejected',
default => null,
};

if ($motionLifecycle !== null) {
// Only transition to defined terminal states via the guarded state machine.
$motionLifecycle = match ($result) {
'adopted' => 'adopted',
'rejected' => 'rejected',
default => null,
};

if ($motionLifecycle !== null) {
try {
$this->motionService->transitionLifecycle(
objectId: $motionId,
objectType: 'motion',
Expand All @@ -545,22 +551,48 @@ public function closeVotingRound(string $votingRoundId, bool $anonymise=false):
$motionTitle = (string) ($motion['title'] ?? $motionId);
$this->createDossierFolder(motionId: $motionId, motionTitle: $motionTitle);
}
}
} catch (\Throwable $e) {
$this->logger->warning('Decidesk: lifecycle transition after close failed', ['error' => $e->getMessage()]);
}//end try
} catch (\InvalidArgumentException $e) {
// State-machine violation: re-throw so the caller can surface it.
// #318: Previously swallowed silently.
throw new \RuntimeException(
'Stemronde gesloten maar motie kon niet worden bijgewerkt: '.$e->getMessage(),
0,
$e
);
} catch (\Throwable $e) {
// Transient infrastructure failure: log at ERROR level and continue.
// #318: Previously logged at WARNING and lost in monitoring noise.
$this->logger->error(
'Decidesk: lifecycle transition after close failed — round is closed but motion state may be stale',
['votingRoundId' => $votingRoundId, 'motionId' => $motionId, 'error' => $e->getMessage()]
);
$lifecycleError = $e->getMessage();
}//end try
}//end if
}//end if

break;
}//end if
}//end foreach
}//end if

// Trigger ORI publication (fails silently if not configured).
// Trigger ORI publication.
// #318: Publication failures are now surfaced to callers when they indicate a real
// configuration or protocol error (not "endpoint not configured" — that is still
// swallowed, as `publish()` returns silently when no endpoint is set).
// Infrastructure/network errors are logged at ERROR level (was INFO) so they surface
// in monitoring.
try {
$this->oriPublicationService->publish($votingRoundId);
} catch (\Throwable $e) {
$this->logger->info('Decidesk: ORI publication deferred', ['error' => $e->getMessage()]);
$this->logger->error(
'Decidesk: ORI publication failed after round close',
['votingRoundId' => $votingRoundId, 'error' => $e->getMessage()]
);
// Attach ORI error to round data so caller can reflect it in the response.
if ($round !== null) {
$round['oriPublicationError'] = $e->getMessage();
}
}

// Anonymise vote values if requested (sequence: tally → publish → anonymise).
Expand Down