Skip to content
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
9 changes: 6 additions & 3 deletions src/Data/Casts/FlexibleDateTimeCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,25 @@

/**
* Casts any date value accepted by the "date" validation rule, such as
* "2023-11-07 05:31:56" or ISO 8601 ("2023-11-07T05:31:56Z"), to Carbon.
* "2023-11-07 05:31:56" or ISO 8601 ("2023-11-07T05:31:56Z"), to Carbon,
* normalized to the application timezone. Values carrying an explicit UTC
* offset are converted to the equivalent application-timezone instant;
* offset-less values are interpreted as application-timezone wall clock.
*/
final class FlexibleDateTimeCast implements Cast
{
public function cast(DataProperty $property, mixed $value, array $properties, CreationContext $context): DateTimeInterface|Uncastable
{
if ($value instanceof DateTimeInterface) {
return Carbon::instance($value);
return Carbon::instance($value)->setTimezone(config('app.timezone'));
}

if (! is_string($value) && ! is_int($value) && ! is_float($value)) {
return Uncastable::create();
}

try {
return Carbon::parse($value);
return Carbon::parse($value)->setTimezone(config('app.timezone'));
} catch (InvalidFormatException) {
return Uncastable::create();
}
Expand Down
9 changes: 7 additions & 2 deletions src/Data/Requests/Incident/CreateIncidentRequestData.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
namespace Cachet\Data\Requests\Incident;

use Cachet\Data\BaseData;
use Cachet\Data\Casts\FlexibleDateTimeCast;
use Cachet\Enums\ComponentStatusEnum;
use Cachet\Enums\IncidentStatusEnum;
use Cachet\Models\Component;
use Carbon\Carbon;
use Illuminate\Validation\Rule;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\Attributes\Validation\Enum;
use Spatie\LaravelData\Attributes\Validation\Exists;
use Spatie\LaravelData\Attributes\Validation\Max;
use Spatie\LaravelData\Attributes\Validation\RequiredWithout;
use Spatie\LaravelData\Attributes\WithCast;
use Spatie\LaravelData\Support\Validation\ValidationContext;

final class CreateIncidentRequestData extends BaseData
Expand All @@ -28,8 +31,10 @@ public function __construct(
public readonly bool $visible = false,
public readonly bool $stickied = false,
public readonly bool $notifications = false,
public readonly ?string $occurredAt = null,
public readonly ?string $publishedAt = null,
#[WithCast(FlexibleDateTimeCast::class)]
public readonly ?Carbon $occurredAt = null,
#[WithCast(FlexibleDateTimeCast::class)]
public readonly ?Carbon $publishedAt = null,
public readonly array $templateVars = [],
#[Exists(Component::class, 'id')]
public readonly ?int $componentId = null,
Expand Down
9 changes: 7 additions & 2 deletions src/Data/Requests/Incident/UpdateIncidentRequestData.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
namespace Cachet\Data\Requests\Incident;

use Cachet\Data\BaseData;
use Cachet\Data\Casts\FlexibleDateTimeCast;
use Cachet\Enums\IncidentStatusEnum;
use Carbon\Carbon;
use Illuminate\Validation\Rule;
use Spatie\LaravelData\Attributes\WithCast;
use Spatie\LaravelData\Optional;
use Spatie\LaravelData\Support\Validation\ValidationContext;

Expand All @@ -17,8 +20,10 @@ public function __construct(
public readonly ?bool $visible = null,
public readonly ?bool $stickied = null,
public readonly ?bool $notifications = null,
public readonly ?string $occurredAt = null,
public readonly ?string $publishedAt = null,
#[WithCast(FlexibleDateTimeCast::class)]
public readonly ?Carbon $occurredAt = null,
#[WithCast(FlexibleDateTimeCast::class)]
public readonly ?Carbon $publishedAt = null,
/** @var array<string, mixed>|null */
public readonly ?array $meta = null,
) {}
Expand Down
6 changes: 5 additions & 1 deletion src/Data/Requests/Metric/CreateMetricPointRequestData.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@
namespace Cachet\Data\Requests\Metric;

use Cachet\Data\BaseData;
use Cachet\Data\Casts\FlexibleDateTimeCast;
use Cachet\Rules\ValidTimestamp;
use Carbon\Carbon;
use Spatie\LaravelData\Attributes\WithCast;
use Spatie\LaravelData\Support\Validation\ValidationContext;

final class CreateMetricPointRequestData extends BaseData
{
public function __construct(
public readonly float $value,
public readonly mixed $timestamp = null,
#[WithCast(FlexibleDateTimeCast::class)]
public readonly ?Carbon $timestamp = null,
) {}

public static function rules(ValidationContext $context): array
Expand Down
2 changes: 1 addition & 1 deletion src/Models/MetricPoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ public function createdAt(): Attribute
$timestamp = $createdAt->getTimestamp();
$timestamp = 30 * round($timestamp / 30);

return Carbon::createFromTimestamp($timestamp);
return Carbon::createFromTimestamp($timestamp, config('app.timezone'));
}
);
}
Expand Down
25 changes: 25 additions & 0 deletions tests/Feature/Api/ScheduleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,31 @@
]);
});

it('can create a schedule with ISO 8601 dates carrying a non-UTC offset', function () {
Sanctum::actingAs(User::factory()->create(), ['schedules.manage']);

$response = postJson('/status/api/schedules', [
'name' => 'New Scheduled Maintenance',
'message' => 'Something will go wrong.',
'scheduled_at' => '2033-11-07T05:31:56+05:30',
'completed_at' => '2033-11-08T05:31:56+05:30',
]);

$response->assertCreated();
$response->assertJson([
'data' => [
'attributes' => [
'scheduled' => [
'string' => '2033-11-07 00:01:56',
],
'completed' => [
'string' => '2033-11-08 00:01:56',
],
],
],
]);
});

it('responds with a JSON validation error when the Accept header is not set', function () {
Sanctum::actingAs(User::factory()->create(), ['schedules.manage']);

Expand Down
179 changes: 179 additions & 0 deletions tests/Feature/Api/TimezoneNormalizationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<?php

use Cachet\Models\Incident;
use Cachet\Models\Metric;
use Cachet\Models\Schedule;
use Laravel\Sanctum\Sanctum;
use Workbench\App\User;

use function Pest\Laravel\postJson;
use function Pest\Laravel\putJson;

afterEach(function () {
config()->set('app.timezone', 'UTC');
date_default_timezone_set('UTC');
});

function useAppTimezone(string $timezone): void
{
config()->set('app.timezone', $timezone);
date_default_timezone_set($timezone);
}

it('normalizes incident occurred_at with a UTC designator to the app timezone', function () {
useAppTimezone('Europe/Berlin');

Sanctum::actingAs(User::factory()->create(), ['incidents.manage']);

$response = postJson('/status/api/incidents', [
'name' => 'Incident',
'message' => 'Something went wrong.',
'status' => 1,
'occurred_at' => '2026-08-06T18:31:37Z',
]);

$response->assertCreated();
$response->assertJsonPath('data.attributes.occurred.string', '2026-08-06 20:31:37');
$this->assertDatabaseHas('incidents', ['occurred_at' => '2026-08-06 20:31:37']);
});

it('normalizes incident occurred_at with an explicit offset to the app timezone', function () {
useAppTimezone('Europe/Berlin');

Sanctum::actingAs(User::factory()->create(), ['incidents.manage']);

$response = postJson('/status/api/incidents', [
'name' => 'Incident',
'message' => 'Something went wrong.',
'status' => 1,
'occurred_at' => '2026-08-06T18:31:37+05:30',
]);

$response->assertCreated();
$response->assertJsonPath('data.attributes.occurred.string', '2026-08-06 15:01:37');
$this->assertDatabaseHas('incidents', ['occurred_at' => '2026-08-06 15:01:37']);
});

it('interprets offset-less incident occurred_at as app timezone wall clock', function () {
useAppTimezone('Europe/Berlin');

Sanctum::actingAs(User::factory()->create(), ['incidents.manage']);

$response = postJson('/status/api/incidents', [
'name' => 'Incident',
'message' => 'Something went wrong.',
'status' => 1,
'occurred_at' => '2026-08-06 18:31:37',
]);

$response->assertCreated();
$response->assertJsonPath('data.attributes.occurred.string', '2026-08-06 18:31:37');
$this->assertDatabaseHas('incidents', ['occurred_at' => '2026-08-06 18:31:37']);
});

it('normalizes incident published_at with an explicit offset to the app timezone', function () {
useAppTimezone('Europe/Berlin');

Sanctum::actingAs(User::factory()->create(), ['incidents.manage']);

$response = postJson('/status/api/incidents', [
'name' => 'Incident',
'message' => 'Something went wrong.',
'status' => 1,
'published_at' => '2026-08-06T18:31:37+05:30',
]);

$response->assertCreated();
$this->assertDatabaseHas('incidents', ['published_at' => '2026-08-06 15:01:37']);
});

it('normalizes occurred_at on incident update', function () {
useAppTimezone('Europe/Berlin');

Sanctum::actingAs(User::factory()->create(), ['incidents.manage']);

$incident = Incident::factory()->create();

$response = putJson('/status/api/incidents/'.$incident->id, [
'occurred_at' => '2026-08-06T18:31:37+05:30',
]);

$response->assertOk();
$this->assertDatabaseHas('incidents', [
'id' => $incident->id,
'occurred_at' => '2026-08-06 15:01:37',
]);
});

it('normalizes schedule scheduled_at and completed_at with explicit offsets to the app timezone', function () {
useAppTimezone('Europe/Berlin');

Sanctum::actingAs(User::factory()->create(), ['schedules.manage']);

$response = postJson('/status/api/schedules', [
'name' => 'Maintenance',
'message' => 'Something will go wrong.',
'scheduled_at' => '2026-08-06T18:31:37Z',
'completed_at' => '2026-08-06T22:31:37+05:30',
]);

$response->assertCreated();
$response->assertJsonPath('data.attributes.scheduled.string', '2026-08-06 20:31:37');
$response->assertJsonPath('data.attributes.completed.string', '2026-08-06 19:01:37');
$this->assertDatabaseHas('schedules', [
'scheduled_at' => '2026-08-06 20:31:37',
'completed_at' => '2026-08-06 19:01:37',
]);
});

it('normalizes completed_at with an explicit offset on schedule update', function () {
useAppTimezone('Europe/Berlin');

Sanctum::actingAs(User::factory()->create(), ['schedules.manage']);

$schedule = Schedule::factory()->create();

$response = putJson('/status/api/schedules/'.$schedule->id, [
'completed_at' => '2026-08-06T18:31:37+05:30',
]);

$response->assertOk();
$this->assertDatabaseHas('schedules', [
'id' => $schedule->id,
'completed_at' => '2026-08-06 15:01:37',
]);
});

it('normalizes an ISO 8601 metric point timestamp with an explicit offset to the app timezone', function () {
useAppTimezone('Europe/Berlin');

Sanctum::actingAs(User::factory()->create(), ['metric-points.manage']);

$metric = Metric::factory()->create();

$response = postJson('/status/api/metrics/'.$metric->id.'/points', [
'value' => 1,
'timestamp' => '2026-08-06T18:31:30+05:30',
]);

$response->assertCreated();
$this->assertDatabaseHas('metric_points', [
'metric_id' => $metric->id,
'created_at' => '2026-08-06 15:01:30',
]);
});

it('normalizes incident occurred_at with an explicit offset when the app timezone is UTC', function () {
Sanctum::actingAs(User::factory()->create(), ['incidents.manage']);

$response = postJson('/status/api/incidents', [
'name' => 'Incident',
'message' => 'Something went wrong.',
'status' => 1,
'occurred_at' => '2026-08-06T18:31:37+05:30',
]);

$response->assertCreated();
$response->assertJsonPath('data.attributes.occurred.string', '2026-08-06 13:01:37');
$this->assertDatabaseHas('incidents', ['occurred_at' => '2026-08-06 13:01:37']);
});
Loading