From d3fb1692b346bccc3e369218687e1c44eda4c57a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20L=C3=BCtzner?= Date: Sat, 1 Aug 2026 00:33:59 +0200 Subject: [PATCH] Add the calendar data layer: JSCalendar events with materialised occurrences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storage is JSCalendar (RFC 8984) in jsonb, with everything a query touches projected into real columns. Both halves earn their place. JSCalendar is canonical because everything this will ever talk to speaks it or converts cleanly — iCalendar both ways, CalDAV, JMAP calendars when that draft lands — and a bespoke schema gets re-derived badly the first time an .ics round-trips, silently dropping participants, alerts and overrides. But Postgres cannot do range logic on "PT1H", so the columns exist too. Recurrence is the part that is easy to get almost right, and there are only three options. Expanding RRULEs at query time means loading every recurring event ever created to answer "what is in July" — a standup from 2019 still generates instances — and nothing is indexable. Materialising everything does not terminate for FREQ=DAILY with no UNTIL. So: materialise to a bounded horizon, roll it forward nightly, and fall back to live expansion outside it. calendar_event_occurrence carries a GENERATED tsrange, and views read it with `span && tsrange(from, to)` against a GiST index. The naive `starts_at < :to AND ends_at > :from` is not just slower — it silently misses the conference that started last week and is still running, because starts_at ordering stops approximating ends_at ordering once multi-day events exist. That index lives only in the migration: this DBAL has no flag for USING gist, so every schema diff asks to drop it, and nothing here may run doctrine:schema:update. Two findings from writing the tests: * sabre accepts FREQ=SECONDLY and FREQ=MINUTELY at validation but its iterator has no branch to advance on either, so it yields one instant forever. The occurrence cap turned that into a thousand identical rows — a worse failure than a hang, because it looks like it worked. The converter now refuses both frequencies, and the materialiser stops on any rule that fails to move forward. * Expansion has to happen in the event's own zone. A 09:00 Berlin standup is 08:00 UTC in winter and 07:00 UTC in summer; expanding in UTC passes every test written in UTC and moves everyone's meeting twice a year. Pinned by testWeeklyExpansionHoldsLocalTimeAcrossADst- Transition. Provenance tables (event_source_link, event_suppression) ship now though nothing writes them yet: they are what will let extraction re-run over a whole mailbox as a backfill rather than a resync, and a suppression the user has already expressed must survive that. Co-Authored-By: Claude Opus 5 --- composer.json | 1 + composer.lock | 240 +++++++++- config/packages/doctrine.yaml | 10 + migrations/Version20260731222453.php | 117 +++++ phpstan-baseline.neon | 126 +++++ src/Domain/Enum/Calendar/CalendarRole.php | 42 ++ src/Domain/Enum/Calendar/CalendarView.php | 54 +++ src/Domain/Enum/Calendar/EventPrivacy.php | 21 + src/Domain/Enum/Calendar/EventSource.php | 49 ++ src/Domain/Enum/Calendar/EventStatus.php | 20 + src/Domain/Enum/Calendar/ExtractionKind.php | 53 +++ src/Entity/Calendar/Calendar.php | 124 +++++ src/Entity/Calendar/CalendarEvent.php | 198 ++++++++ .../Calendar/CalendarEventOccurrence.php | 115 +++++ src/Entity/Calendar/EventSourceLink.php | 90 ++++ src/Entity/Calendar/EventSuppression.php | 50 ++ .../CalendarEventOccurrenceRepository.php | 157 +++++++ .../Calendar/CalendarEventRepository.php | 52 +++ .../Calendar/CalendarRepository.php | 63 +++ .../Calendar/EventSourceLinkRepository.php | 41 ++ .../Calendar/EventSuppressionRepository.php | 35 ++ .../Calendar/RecurrenceMaterialiser.php | 343 ++++++++++++++ .../Calendar/RecurrenceRuleConverter.php | 219 +++++++++ .../CalendarEventOccurrenceRepositoryTest.php | 230 ++++++++++ .../Calendar/RecurrenceMaterialiserTest.php | 431 ++++++++++++++++++ 25 files changed, 2880 insertions(+), 1 deletion(-) create mode 100644 migrations/Version20260731222453.php create mode 100644 src/Domain/Enum/Calendar/CalendarRole.php create mode 100644 src/Domain/Enum/Calendar/CalendarView.php create mode 100644 src/Domain/Enum/Calendar/EventPrivacy.php create mode 100644 src/Domain/Enum/Calendar/EventSource.php create mode 100644 src/Domain/Enum/Calendar/EventStatus.php create mode 100644 src/Domain/Enum/Calendar/ExtractionKind.php create mode 100644 src/Entity/Calendar/Calendar.php create mode 100644 src/Entity/Calendar/CalendarEvent.php create mode 100644 src/Entity/Calendar/CalendarEventOccurrence.php create mode 100644 src/Entity/Calendar/EventSourceLink.php create mode 100644 src/Entity/Calendar/EventSuppression.php create mode 100644 src/Repository/Calendar/CalendarEventOccurrenceRepository.php create mode 100644 src/Repository/Calendar/CalendarEventRepository.php create mode 100644 src/Repository/Calendar/CalendarRepository.php create mode 100644 src/Repository/Calendar/EventSourceLinkRepository.php create mode 100644 src/Repository/Calendar/EventSuppressionRepository.php create mode 100644 src/Service/Calendar/RecurrenceMaterialiser.php create mode 100644 src/Service/Calendar/RecurrenceRuleConverter.php create mode 100644 tests/Repository/Calendar/CalendarEventOccurrenceRepositoryTest.php create mode 100644 tests/Service/Calendar/RecurrenceMaterialiserTest.php diff --git a/composer.json b/composer.json index c5b88b64..7feb9510 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "minishlink/web-push": "^11.0", "phpdocumentor/reflection-docblock": "^6.0", "phpstan/phpdoc-parser": "^2.3.3", + "sabre/vobject": "^4.6", "scheb/2fa-backup-code": "^8.6", "scheb/2fa-bundle": "^8.6", "scheb/2fa-totp": "^8.6", diff --git a/composer.lock b/composer.lock index ef7a4905..8d49bf07 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "48751d4b810eaedec17108f38e448e9b", + "content-hash": "1cb4911258a82b2ed339c7007adac711", "packages": [ { "name": "bacon/bacon-qr-code", @@ -4460,6 +4460,244 @@ }, "time": "2019-03-08T08:55:37+00:00" }, + { + "name": "sabre/uri", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/sabre-io/uri.git", + "reference": "a926c749dddfb289b8a9b5218d16ac06affdc631" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sabre-io/uri/zipball/a926c749dddfb289b8a9b5218d16ac06affdc631", + "reference": "a926c749dddfb289b8a9b5218d16ac06affdc631", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^10.5", + "rector/rector": "^2.4" + }, + "type": "library", + "autoload": { + "files": [ + "lib/functions.php" + ], + "psr-4": { + "Sabre\\Uri\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Evert Pot", + "email": "me@evertpot.com", + "homepage": "http://evertpot.com/", + "role": "Developer" + } + ], + "description": "Functions for making sense out of URIs.", + "homepage": "http://sabre.io/uri/", + "keywords": [ + "rfc3986", + "uri", + "url" + ], + "support": { + "forum": "https://groups.google.com/group/sabredav-discuss", + "issues": "https://github.com/sabre-io/uri/issues", + "source": "https://github.com/fruux/sabre-uri" + }, + "time": "2026-04-26T04:19:03+00:00" + }, + { + "name": "sabre/vobject", + "version": "4.6.1", + "source": { + "type": "git", + "url": "https://github.com/sabre-io/vobject.git", + "reference": "63613f6c53a0a2bddfe22caba0d052e6c59f7d0e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sabre-io/vobject/zipball/63613f6c53a0a2bddfe22caba0d052e6c59f7d0e", + "reference": "63613f6c53a0a2bddfe22caba0d052e6c59f7d0e", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabre/xml": "^2.1 || ^3.0 || ^4.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.17.1", + "phpstan/phpstan": "^0.12 || ^1.12 || ^2.0", + "phpunit/php-invoker": "^2.0 || ^3.1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6" + }, + "suggest": { + "hoa/bench": "If you would like to run the benchmark scripts" + }, + "bin": [ + "bin/vobject", + "bin/generate_vcards" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Sabre\\VObject\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Evert Pot", + "email": "me@evertpot.com", + "homepage": "http://evertpot.com/", + "role": "Developer" + }, + { + "name": "Dominik Tobschall", + "email": "dominik@fruux.com", + "homepage": "http://tobschall.de/", + "role": "Developer" + }, + { + "name": "Ivan Enderlin", + "email": "ivan.enderlin@hoa-project.net", + "homepage": "http://mnt.io/", + "role": "Developer" + } + ], + "description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects", + "homepage": "http://sabre.io/vobject/", + "keywords": [ + "availability", + "freebusy", + "iCalendar", + "ical", + "ics", + "jCal", + "jCard", + "recurrence", + "rfc2425", + "rfc2426", + "rfc2739", + "rfc4770", + "rfc5545", + "rfc5546", + "rfc6321", + "rfc6350", + "rfc6351", + "rfc6474", + "rfc6638", + "rfc6715", + "rfc6868", + "vCalendar", + "vCard", + "vcf", + "xCal", + "xCard" + ], + "support": { + "forum": "https://groups.google.com/group/sabredav-discuss", + "issues": "https://github.com/sabre-io/vobject/issues", + "source": "https://github.com/fruux/sabre-vobject" + }, + "time": "2026-07-07T03:20:17+00:00" + }, + { + "name": "sabre/xml", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/sabre-io/xml.git", + "reference": "bec83cbe2f4e1e1ca4254ed8d7caa7e32cc74b05" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sabre-io/xml/zipball/bec83cbe2f4e1e1ca4254ed8d7caa7e32cc74b05", + "reference": "bec83cbe2f4e1e1ca4254ed8d7caa7e32cc74b05", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "lib-libxml": ">=2.6.20", + "php": "^8.2", + "sabre/uri": ">=2.0,<4.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^10.5", + "rector/rector": "^2.4" + }, + "type": "library", + "autoload": { + "files": [ + "lib/Deserializer/functions.php", + "lib/Serializer/functions.php" + ], + "psr-4": { + "Sabre\\Xml\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Evert Pot", + "email": "me@evertpot.com", + "homepage": "http://evertpot.com/", + "role": "Developer" + }, + { + "name": "Markus Staab", + "email": "markus.staab@redaxo.de", + "role": "Developer" + } + ], + "description": "sabre/xml is an XML library that you may not hate.", + "homepage": "https://sabre.io/xml/", + "keywords": [ + "XMLReader", + "XMLWriter", + "dom", + "xml" + ], + "support": { + "forum": "https://groups.google.com/group/sabredav-discuss", + "issues": "https://github.com/sabre-io/xml/issues", + "source": "https://github.com/fruux/sabre-xml" + }, + "time": "2026-04-27T10:56:01+00:00" + }, { "name": "scheb/2fa-backup-code", "version": "v8.6.1", diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index b96702ec..2d6cc8fe 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -6,6 +6,16 @@ doctrine: # TEXT column, encrypted at rest. See App\Infrastructure\Doctrine\Type\EncryptedStringType. encrypted_string: App\Infrastructure\Doctrine\Type\EncryptedStringType + mapping_types: + # calendar_event_occurrence.span is a GENERATED tsrange that only + # raw SQL ever touches, but schema:validate still has to be able to + # name its type when it reads the database back. DBAL knows tsvector + # natively (message.search_vector) and does not know tsrange, so say + # so here. text, matching how DBAL already maps tsvector, so the + # comparator sees the same type on both sides and stops asking to + # rewrite the column on every diff. + tsrange: text + # IMPORTANT: You MUST configure your server version, # either here or in the DATABASE_URL env var (see .env file) diff --git a/migrations/Version20260731222453.php b/migrations/Version20260731222453.php new file mode 100644 index 00000000..6dec8c21 --- /dev/null +++ b/migrations/Version20260731222453.php @@ -0,0 +1,117 @@ +addSql('CREATE TABLE calendar (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, name VARCHAR(120) NOT NULL, color VARCHAR(7) DEFAULT \'#2563eb\' NOT NULL, time_zone VARCHAR(64) DEFAULT \'UTC\' NOT NULL, role VARCHAR(20) DEFAULT \'custom\' NOT NULL, is_visible BOOLEAN DEFAULT true NOT NULL, is_default BOOLEAN DEFAULT false NOT NULL, is_read_only BOOLEAN DEFAULT false NOT NULL, sort_order INT DEFAULT 0 NOT NULL, remote_id VARCHAR(255) DEFAULT NULL, sync_token TEXT DEFAULT NULL, settings JSONB DEFAULT \'{}\' NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, usr_id INT NOT NULL, account_id INT DEFAULT NULL, integration_id INT DEFAULT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_6EA9A1469E82DDEA ON calendar (integration_id)'); + $this->addSql('CREATE INDEX idx_calendar_usr ON calendar (usr_id)'); + $this->addSql('CREATE INDEX idx_calendar_account ON calendar (account_id)'); + $this->addSql('CREATE TABLE calendar_event (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, uid VARCHAR(255) NOT NULL, sequence INT DEFAULT 0 NOT NULL, starts_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, ends_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, time_zone VARCHAR(64) DEFAULT NULL, is_all_day BOOLEAN DEFAULT false NOT NULL, title TEXT DEFAULT NULL, location TEXT DEFAULT NULL, status VARCHAR(16) DEFAULT \'confirmed\' NOT NULL, privacy VARCHAR(16) DEFAULT \'public\' NOT NULL, kind VARCHAR(24) DEFAULT NULL, source VARCHAR(20) DEFAULT \'manual\' NOT NULL, confidence SMALLINT DEFAULT 100 NOT NULL, is_recurring BOOLEAN DEFAULT false NOT NULL, recurrence_until TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, is_user_edited BOOLEAN DEFAULT false NOT NULL, dedup_key_version SMALLINT DEFAULT 1 NOT NULL, jscalendar JSONB DEFAULT \'{}\' NOT NULL, remote_id VARCHAR(255) DEFAULT NULL, remote_etag VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, calendar_id INT NOT NULL, usr_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_57FA09C9A40A2C8 ON calendar_event (calendar_id)'); + $this->addSql('CREATE INDEX idx_calendar_event_usr ON calendar_event (usr_id)'); + $this->addSql('CREATE INDEX idx_calendar_event_calendar_starts ON calendar_event (calendar_id, starts_at)'); + $this->addSql('CREATE INDEX idx_calendar_event_uid ON calendar_event (uid)'); + $this->addSql('CREATE INDEX idx_calendar_event_kind ON calendar_event (kind)'); + $this->addSql('CREATE UNIQUE INDEX uniq_calendar_event_calendar_uid ON calendar_event (calendar_id, uid)'); + $this->addSql('CREATE TABLE calendar_event_occurrence (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, recurrence_id TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, starts_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, ends_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, is_override BOOLEAN DEFAULT false NOT NULL, cancelled BOOLEAN DEFAULT false NOT NULL, span tsrange GENERATED ALWAYS AS (tsrange(starts_at, ends_at, \'[)\')) STORED, event_id INT NOT NULL, calendar_id INT NOT NULL, usr_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_A77CFB6F71F7E88B ON calendar_event_occurrence (event_id)'); + $this->addSql('CREATE INDEX IDX_A77CFB6FA40A2C8 ON calendar_event_occurrence (calendar_id)'); + $this->addSql('CREATE INDEX IDX_A77CFB6FC69D3FB ON calendar_event_occurrence (usr_id)'); + $this->addSql('CREATE INDEX idx_ceo_usr_starts ON calendar_event_occurrence (usr_id, starts_at)'); + $this->addSql('CREATE INDEX idx_ceo_calendar_starts ON calendar_event_occurrence (calendar_id, starts_at)'); + $this->addSql('CREATE UNIQUE INDEX uniq_ceo_event_recurrence ON calendar_event_occurrence (event_id, recurrence_id)'); + + // The index every calendar view actually uses. btree_gist is what lets + // an int sit beside a range in one GiST index; without it this has to + // become two indexes and the planner stops being able to use both. + $this->addSql('CREATE EXTENSION IF NOT EXISTS btree_gist'); + $this->addSql('CREATE INDEX idx_ceo_span ON calendar_event_occurrence USING gist (calendar_id, span)'); + + $this->addSql('CREATE TABLE event_source_link (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, extractor VARCHAR(64) NOT NULL, confidence SMALLINT DEFAULT 0 NOT NULL, dedup_key VARCHAR(128) NOT NULL, applied BOOLEAN DEFAULT true NOT NULL, payload JSONB DEFAULT \'{}\' NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, event_id INT NOT NULL, message_id INT NOT NULL, message_part_id INT DEFAULT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_9CAFCF7271F7E88B ON event_source_link (event_id)'); + $this->addSql('CREATE INDEX IDX_9CAFCF72DC6B71E0 ON event_source_link (message_part_id)'); + $this->addSql('CREATE INDEX idx_event_source_link_message ON event_source_link (message_id)'); + $this->addSql('CREATE INDEX idx_event_source_link_dedup ON event_source_link (dedup_key)'); + $this->addSql('CREATE UNIQUE INDEX uniq_event_source_link ON event_source_link (event_id, message_id, extractor)'); + $this->addSql('CREATE TABLE event_suppression (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, dedup_key_hash CHAR(64) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, usr_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_24B19C69D3FB ON event_suppression (usr_id)'); + $this->addSql('CREATE UNIQUE INDEX uniq_event_suppression ON event_suppression (usr_id, dedup_key_hash)'); + $this->addSql('ALTER TABLE calendar ADD CONSTRAINT FK_6EA9A146C69D3FB FOREIGN KEY (usr_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE calendar ADD CONSTRAINT FK_6EA9A1469B6B5FBA FOREIGN KEY (account_id) REFERENCES account (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE calendar ADD CONSTRAINT FK_6EA9A1469E82DDEA FOREIGN KEY (integration_id) REFERENCES integration (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE calendar_event ADD CONSTRAINT FK_57FA09C9A40A2C8 FOREIGN KEY (calendar_id) REFERENCES calendar (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE calendar_event ADD CONSTRAINT FK_57FA09C9C69D3FB FOREIGN KEY (usr_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE calendar_event_occurrence ADD CONSTRAINT FK_A77CFB6F71F7E88B FOREIGN KEY (event_id) REFERENCES calendar_event (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE calendar_event_occurrence ADD CONSTRAINT FK_A77CFB6FA40A2C8 FOREIGN KEY (calendar_id) REFERENCES calendar (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE calendar_event_occurrence ADD CONSTRAINT FK_A77CFB6FC69D3FB FOREIGN KEY (usr_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE event_source_link ADD CONSTRAINT FK_9CAFCF7271F7E88B FOREIGN KEY (event_id) REFERENCES calendar_event (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE event_source_link ADD CONSTRAINT FK_9CAFCF72537A1329 FOREIGN KEY (message_id) REFERENCES message (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE event_source_link ADD CONSTRAINT FK_9CAFCF72DC6B71E0 FOREIGN KEY (message_part_id) REFERENCES message_part (id) ON DELETE SET NULL NOT DEFERRABLE'); + $this->addSql('ALTER TABLE event_suppression ADD CONSTRAINT FK_24B19C69D3FB FOREIGN KEY (usr_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE calendar DROP CONSTRAINT FK_6EA9A146C69D3FB'); + $this->addSql('ALTER TABLE calendar DROP CONSTRAINT FK_6EA9A1469B6B5FBA'); + $this->addSql('ALTER TABLE calendar DROP CONSTRAINT FK_6EA9A1469E82DDEA'); + $this->addSql('ALTER TABLE calendar_event DROP CONSTRAINT FK_57FA09C9A40A2C8'); + $this->addSql('ALTER TABLE calendar_event DROP CONSTRAINT FK_57FA09C9C69D3FB'); + $this->addSql('ALTER TABLE calendar_event_occurrence DROP CONSTRAINT FK_A77CFB6F71F7E88B'); + $this->addSql('ALTER TABLE calendar_event_occurrence DROP CONSTRAINT FK_A77CFB6FA40A2C8'); + $this->addSql('ALTER TABLE calendar_event_occurrence DROP CONSTRAINT FK_A77CFB6FC69D3FB'); + $this->addSql('ALTER TABLE event_source_link DROP CONSTRAINT FK_9CAFCF7271F7E88B'); + $this->addSql('ALTER TABLE event_source_link DROP CONSTRAINT FK_9CAFCF72537A1329'); + $this->addSql('ALTER TABLE event_source_link DROP CONSTRAINT FK_9CAFCF72DC6B71E0'); + $this->addSql('ALTER TABLE event_suppression DROP CONSTRAINT FK_24B19C69D3FB'); + $this->addSql('DROP TABLE event_suppression'); + $this->addSql('DROP TABLE event_source_link'); + $this->addSql('DROP TABLE calendar_event_occurrence'); + $this->addSql('DROP TABLE calendar_event'); + $this->addSql('DROP TABLE calendar'); + + // btree_gist is left installed on purpose: dropping an extension other + // features may already depend on is not this migration's business. + } +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 13fc09e1..cfaafc4f 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -252,6 +252,126 @@ parameters: count: 1 path: src/Domain/Enum/Integration/Provider.php + - + message: '#^Property App\\Entity\\Calendar\\Calendar\:\:\$createdAt type mapping mismatch\: property can contain DateTimeImmutable\|null but database expects DateTimeImmutable\.$#' + identifier: doctrine.columnType + count: 1 + path: src/Entity/Calendar/Calendar.php + + - + message: '#^Property App\\Entity\\Calendar\\Calendar\:\:\$updatedAt type mapping mismatch\: property can contain DateTimeImmutable\|null but database expects DateTimeImmutable\.$#' + identifier: doctrine.columnType + count: 1 + path: src/Entity/Calendar/Calendar.php + + - + message: '#^Property App\\Entity\\Calendar\\Calendar\:\:\$usr type mapping mismatch\: property can contain App\\Entity\\User\\User\|null but database expects App\\Entity\\User\\User\.$#' + identifier: doctrine.associationType + count: 1 + path: src/Entity/Calendar/Calendar.php + + - + message: '#^Property App\\Entity\\Calendar\\CalendarEvent\:\:\$calendar type mapping mismatch\: property can contain App\\Entity\\Calendar\\Calendar\|null but database expects App\\Entity\\Calendar\\Calendar\.$#' + identifier: doctrine.associationType + count: 1 + path: src/Entity/Calendar/CalendarEvent.php + + - + message: '#^Property App\\Entity\\Calendar\\CalendarEvent\:\:\$createdAt type mapping mismatch\: property can contain DateTimeImmutable\|null but database expects DateTimeImmutable\.$#' + identifier: doctrine.columnType + count: 1 + path: src/Entity/Calendar/CalendarEvent.php + + - + message: '#^Property App\\Entity\\Calendar\\CalendarEvent\:\:\$endsAt type mapping mismatch\: property can contain DateTimeImmutable\|null but database expects DateTimeImmutable\.$#' + identifier: doctrine.columnType + count: 1 + path: src/Entity/Calendar/CalendarEvent.php + + - + message: '#^Property App\\Entity\\Calendar\\CalendarEvent\:\:\$startsAt type mapping mismatch\: property can contain DateTimeImmutable\|null but database expects DateTimeImmutable\.$#' + identifier: doctrine.columnType + count: 1 + path: src/Entity/Calendar/CalendarEvent.php + + - + message: '#^Property App\\Entity\\Calendar\\CalendarEvent\:\:\$updatedAt type mapping mismatch\: property can contain DateTimeImmutable\|null but database expects DateTimeImmutable\.$#' + identifier: doctrine.columnType + count: 1 + path: src/Entity/Calendar/CalendarEvent.php + + - + message: '#^Property App\\Entity\\Calendar\\CalendarEvent\:\:\$usr type mapping mismatch\: property can contain App\\Entity\\User\\User\|null but database expects App\\Entity\\User\\User\.$#' + identifier: doctrine.associationType + count: 1 + path: src/Entity/Calendar/CalendarEvent.php + + - + message: '#^Property App\\Entity\\Calendar\\CalendarEventOccurrence\:\:\$calendar type mapping mismatch\: property can contain App\\Entity\\Calendar\\Calendar\|null but database expects App\\Entity\\Calendar\\Calendar\.$#' + identifier: doctrine.associationType + count: 1 + path: src/Entity/Calendar/CalendarEventOccurrence.php + + - + message: '#^Property App\\Entity\\Calendar\\CalendarEventOccurrence\:\:\$endsAt type mapping mismatch\: property can contain DateTimeImmutable\|null but database expects DateTimeImmutable\.$#' + identifier: doctrine.columnType + count: 1 + path: src/Entity/Calendar/CalendarEventOccurrence.php + + - + message: '#^Property App\\Entity\\Calendar\\CalendarEventOccurrence\:\:\$event type mapping mismatch\: property can contain App\\Entity\\Calendar\\CalendarEvent\|null but database expects App\\Entity\\Calendar\\CalendarEvent\.$#' + identifier: doctrine.associationType + count: 1 + path: src/Entity/Calendar/CalendarEventOccurrence.php + + - + message: '#^Property App\\Entity\\Calendar\\CalendarEventOccurrence\:\:\$recurrenceId type mapping mismatch\: property can contain DateTimeImmutable\|null but database expects DateTimeImmutable\.$#' + identifier: doctrine.columnType + count: 1 + path: src/Entity/Calendar/CalendarEventOccurrence.php + + - + message: '#^Property App\\Entity\\Calendar\\CalendarEventOccurrence\:\:\$startsAt type mapping mismatch\: property can contain DateTimeImmutable\|null but database expects DateTimeImmutable\.$#' + identifier: doctrine.columnType + count: 1 + path: src/Entity/Calendar/CalendarEventOccurrence.php + + - + message: '#^Property App\\Entity\\Calendar\\CalendarEventOccurrence\:\:\$usr type mapping mismatch\: property can contain App\\Entity\\User\\User\|null but database expects App\\Entity\\User\\User\.$#' + identifier: doctrine.associationType + count: 1 + path: src/Entity/Calendar/CalendarEventOccurrence.php + + - + message: '#^Property App\\Entity\\Calendar\\EventSourceLink\:\:\$createdAt type mapping mismatch\: property can contain DateTimeImmutable\|null but database expects DateTimeImmutable\.$#' + identifier: doctrine.columnType + count: 1 + path: src/Entity/Calendar/EventSourceLink.php + + - + message: '#^Property App\\Entity\\Calendar\\EventSourceLink\:\:\$event type mapping mismatch\: property can contain App\\Entity\\Calendar\\CalendarEvent\|null but database expects App\\Entity\\Calendar\\CalendarEvent\.$#' + identifier: doctrine.associationType + count: 1 + path: src/Entity/Calendar/EventSourceLink.php + + - + message: '#^Property App\\Entity\\Calendar\\EventSourceLink\:\:\$message type mapping mismatch\: property can contain App\\Entity\\Mail\\Message\|null but database expects App\\Entity\\Mail\\Message\.$#' + identifier: doctrine.associationType + count: 1 + path: src/Entity/Calendar/EventSourceLink.php + + - + message: '#^Property App\\Entity\\Calendar\\EventSourceLink\:\:\$updatedAt type mapping mismatch\: property can contain DateTimeImmutable\|null but database expects DateTimeImmutable\.$#' + identifier: doctrine.columnType + count: 1 + path: src/Entity/Calendar/EventSourceLink.php + + - + message: '#^Property App\\Entity\\Calendar\\EventSuppression\:\:\$usr type mapping mismatch\: property can contain App\\Entity\\User\\User\|null but database expects App\\Entity\\User\\User\.$#' + identifier: doctrine.associationType + count: 1 + path: src/Entity/Calendar/EventSuppression.php + - message: '#^Property App\\Entity\\Integration\\Integration\:\:\$createdAt type mapping mismatch\: property can contain DateTimeImmutable\|null but database expects DateTimeImmutable\.$#' identifier: doctrine.columnType @@ -732,6 +852,12 @@ parameters: count: 1 path: src/Kernel.php + - + message: '#^Call to an undefined method Symfony\\Component\\Security\\Core\\User\\UserInterface\:\:getId\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Repository/Calendar/CalendarEventOccurrenceRepository.php + - message: '#^PHPDoc tag @return with type iterable\ is not subtype of native type array\.$#' identifier: return.phpDocType diff --git a/src/Domain/Enum/Calendar/CalendarRole.php b/src/Domain/Enum/Calendar/CalendarRole.php new file mode 100644 index 00000000..a03c6fb1 --- /dev/null +++ b/src/Domain/Enum/Calendar/CalendarRole.php @@ -0,0 +1,42 @@ + false, + self::Custom, self::Remote => true, + }; + } +} diff --git a/src/Domain/Enum/Calendar/CalendarView.php b/src/Domain/Enum/Calendar/CalendarView.php new file mode 100644 index 00000000..8e781b5d --- /dev/null +++ b/src/Domain/Enum/Calendar/CalendarView.php @@ -0,0 +1,54 @@ + [ + $anchor->setTime(0, 0), + $anchor->modify('+1 day')->setTime(0, 0), + ], + self::Week => [ + $anchor->modify('monday this week')->setTime(0, 0), + $anchor->modify('monday this week')->modify('+7 days')->setTime(0, 0), + ], + self::Month => [ + $anchor->modify('first day of this month')->modify('monday this week')->setTime(0, 0), + $anchor->modify('first day of this month')->modify('monday this week')->modify('+42 days')->setTime(0, 0), + ], + self::Agenda => [ + $anchor->setTime(0, 0), + $anchor->modify('+30 days')->setTime(0, 0), + ], + }; + } +} diff --git a/src/Domain/Enum/Calendar/EventPrivacy.php b/src/Domain/Enum/Calendar/EventPrivacy.php new file mode 100644 index 00000000..597971e1 --- /dev/null +++ b/src/Domain/Enum/Calendar/EventPrivacy.php @@ -0,0 +1,21 @@ + 'fa-solid fa-users', + self::Delivery => 'fa-solid fa-box', + self::Flight => 'fa-solid fa-plane', + self::Train => 'fa-solid fa-train', + self::Lodging => 'fa-solid fa-bed', + self::Dining => 'fa-solid fa-utensils', + self::Rental => 'fa-solid fa-car', + self::Ticket => 'fa-solid fa-ticket', + self::Order => 'fa-solid fa-bag-shopping', + self::Call => 'fa-solid fa-phone', + }; + } + + /** Translation key for the user-facing name. */ + public function transKey(): string + { + return 'calendar.kind.' . $this->value; + } +} diff --git a/src/Entity/Calendar/Calendar.php b/src/Entity/Calendar/Calendar.php new file mode 100644 index 00000000..44e386d2 --- /dev/null +++ b/src/Entity/Calendar/Calendar.php @@ -0,0 +1,124 @@ + self::DEFAULT_COLOR])] + public string $color = self::DEFAULT_COLOR; + + /** + * IANA zone this calendar is displayed in. Events carry their own; this is + * the fallback for ones that do not, and the zone a new event starts in. + */ + #[ORM\Column(length: 64, options: ['default' => 'UTC'])] + public string $timeZone = 'UTC'; + + #[ORM\Column(length: 20, enumType: CalendarRole::class, options: ['default' => 'custom'])] + public CalendarRole $role = CalendarRole::Custom; + + /** Unticked in the sidebar hides it from every view without deleting it. */ + #[ORM\Column(options: ['default' => true])] + public bool $isVisible = true; + + /** The one a new event lands in when nothing else picked. */ + #[ORM\Column(options: ['default' => false])] + public bool $isDefault = false; + + /** A mirror of somewhere that does not accept writes back. */ + #[ORM\Column(options: ['default' => false])] + public bool $isReadOnly = false; + + #[ORM\Column(options: ['default' => 0])] + public int $sortOrder = 0; + + /** Opaque id at the remote, for CalendarRole::Remote. */ + #[ORM\Column(length: 255, nullable: true)] + public ?string $remoteId = null; + + /** CalDAV sync-token or provider equivalent; opaque, never parsed. */ + #[ORM\Column(type: Types::TEXT, nullable: true)] + public ?string $syncToken = null; + + /** + * Free-form per-calendar preferences, mirroring Account::$settings — the + * long tail that does not deserve a column. Readers assume their defaults + * at the call site. + * + * @var array + */ + #[ORM\Column(type: Types::JSON, options: ['jsonb' => true, 'default' => '{}'])] + public array $settings = []; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: CalendarEvent::class, mappedBy: 'calendar')] + public private(set) Collection $events; + + public function __construct() + { + $this->events = new ArrayCollection(); + } +} diff --git a/src/Entity/Calendar/CalendarEvent.php b/src/Entity/Calendar/CalendarEvent.php new file mode 100644 index 00000000..321d881c --- /dev/null +++ b/src/Entity/Calendar/CalendarEvent.php @@ -0,0 +1,198 @@ + 0])] + public int $sequence = 0; + + /** UTC. Local midnight when isAllDay. */ + #[ORM\Column] + public ?DateTimeImmutable $startsAt = null; + + /** UTC, exclusive. */ + #[ORM\Column] + public ?DateTimeImmutable $endsAt = null; + + /** IANA zone; null means floating (all-day, or a genuinely zoneless event). */ + #[ORM\Column(length: 64, nullable: true)] + public ?string $timeZone = null; + + #[ORM\Column(options: ['default' => false])] + public bool $isAllDay = false; + + /** Projected from jscalendar.title for lists and search. */ + #[ORM\Column(type: Types::TEXT, nullable: true)] + public ?string $title = null; + + /** Projected from the first jscalendar.locations entry. */ + #[ORM\Column(type: Types::TEXT, nullable: true)] + public ?string $location = null; + + #[ORM\Column(length: 16, enumType: EventStatus::class, options: ['default' => 'confirmed'])] + public EventStatus $status = EventStatus::Confirmed; + + #[ORM\Column(length: 16, enumType: EventPrivacy::class, options: ['default' => 'public'])] + public EventPrivacy $privacy = EventPrivacy::Public; + + /** + * What this event is about, when it was extracted from mail. Null means a + * person made it — which is also what "Happening Soon" filters on, so the + * feature needs no table of its own. + */ + #[ORM\Column(length: 24, nullable: true, enumType: ExtractionKind::class)] + public ?ExtractionKind $kind = null; + + #[ORM\Column(length: 20, enumType: EventSource::class, options: ['default' => 'manual'])] + public EventSource $source = EventSource::Manual; + + /** 0-100. Only meaningful for extracted events; manual is always 100. */ + #[ORM\Column(type: Types::SMALLINT, options: ['default' => 100])] + public int $confidence = 100; + + #[ORM\Column(options: ['default' => false])] + public bool $isRecurring = false; + + /** + * The last moment this event can possibly occur, or null for a recurrence + * with no end. Occurrences are materialised only to a bounded horizon, so + * this is what the nightly sweep looks at to decide whose horizon needs + * extending. + */ + #[ORM\Column(nullable: true)] + public ?DateTimeImmutable $recurrenceUntil = null; + + /** + * Set the moment a person edits an extracted event. The reconciler then + * refuses to overwrite it: a later mail may know more about the booking, + * but it does not know more than the user. + */ + #[ORM\Column(options: ['default' => false])] + public bool $isUserEdited = false; + + /** + * Which formula produced the dedup key on this event's source links. + * + * Changing how a key is derived orphans every event already keyed the old + * way — an update arrives, matches nothing, and becomes a duplicate. The + * column costs a smallint now and makes that a re-keying backfill later + * rather than a data-loss event. + */ + #[ORM\Column(type: Types::SMALLINT, options: ['default' => 1])] + public int $dedupKeyVersion = 1; + + /** + * The canonical JSCalendar object. Everything above is projected from here. + * + * @var array + */ + #[ORM\Column(type: Types::JSON, options: ['jsonb' => true, 'default' => '{}'])] + public array $jscalendar = []; + + /** Opaque id at the remote, for events mirrored from a connected calendar. */ + #[ORM\Column(length: 255, nullable: true)] + public ?string $remoteId = null; + + #[ORM\Column(length: 255, nullable: true)] + public ?string $remoteEtag = null; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: CalendarEventOccurrence::class, mappedBy: 'event', orphanRemoval: true)] + public private(set) Collection $occurrences; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: EventSourceLink::class, mappedBy: 'event', orphanRemoval: true)] + public private(set) Collection $sourceLinks; + + public function __construct() + { + $this->occurrences = new ArrayCollection(); + $this->sourceLinks = new ArrayCollection(); + } + + /** Whether a person, rather than an extractor, is responsible for this. */ + public function isExtracted(): bool + { + return null !== $this->kind; + } +} diff --git a/src/Entity/Calendar/CalendarEventOccurrence.php b/src/Entity/Calendar/CalendarEventOccurrence.php new file mode 100644 index 00000000..47309dcb --- /dev/null +++ b/src/Entity/Calendar/CalendarEventOccurrence.php @@ -0,0 +1,115 @@ + false])] + public bool $isOverride = false; + + /** + * This instance alone is off. Kept rather than deleted for the same reason + * a cancelled event is: the answer to "wasn't there something today?" is + * more useful than a gap. + */ + #[ORM\Column(options: ['default' => false])] + public bool $cancelled = false; + + /** + * The half-open interval this occupies, maintained by Postgres. + * + * Generated rather than written so it cannot disagree with the two columns + * it derives from — the same treatment Message::$searchVector gets, and for + * the same reason. Mapped read-only purely so the schema is complete; + * nothing in PHP reads it, because the only thing that can use it is the + * `&&` overlap in CalendarEventOccurrenceRepository. + */ + #[ORM\Column( + type: Types::TEXT, + nullable: true, + insertable: false, + updatable: false, + columnDefinition: "tsrange GENERATED ALWAYS AS (tsrange(starts_at, ends_at, '[)')) STORED", + )] + public private(set) ?string $span = null; +} diff --git a/src/Entity/Calendar/EventSourceLink.php b/src/Entity/Calendar/EventSourceLink.php new file mode 100644 index 00000000..92c55dee --- /dev/null +++ b/src/Entity/Calendar/EventSourceLink.php @@ -0,0 +1,90 @@ + 0])] + public int $confidence = 0; + + /** + * The identity this extraction claimed, before the event existed. Kept so a + * re-key backfill can find the rows it needs to rewrite — see + * CalendarEvent::$dedupKeyVersion. + */ + #[ORM\Column(length: 128)] + public string $dedupKey = ''; + + /** False means read but superseded; the event does not reflect this row. */ + #[ORM\Column(options: ['default' => true])] + public bool $applied = true; + + /** + * The extracted fragment, verbatim. + * + * @var array + */ + #[ORM\Column(type: Types::JSON, options: ['jsonb' => true, 'default' => '{}'])] + public array $payload = []; +} diff --git a/src/Entity/Calendar/EventSuppression.php b/src/Entity/Calendar/EventSuppression.php new file mode 100644 index 00000000..da957e37 --- /dev/null +++ b/src/Entity/Calendar/EventSuppression.php @@ -0,0 +1,50 @@ + true])] + public string $dedupKeyHash = ''; + + #[ORM\Column] + public DateTimeImmutable $createdAt; + + public function __construct() + { + $this->createdAt = new DateTimeImmutable(); + } +} diff --git a/src/Repository/Calendar/CalendarEventOccurrenceRepository.php b/src/Repository/Calendar/CalendarEventOccurrenceRepository.php new file mode 100644 index 00000000..df2c59d4 --- /dev/null +++ b/src/Repository/Calendar/CalendarEventOccurrenceRepository.php @@ -0,0 +1,157 @@ + + */ +class CalendarEventOccurrenceRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, CalendarEventOccurrence::class); + } + + /** + * Everything overlapping a window — the query every calendar view makes. + * + * Raw DBAL for the id lookup because the test is `span && tsrange(...)`, + * and DQL has no range overlap operator: `&&` is not expressible, and there + * is no function to register that would make it so. Doctrine's own API + * cannot reach a GiST index at all. + * + * The naive DQL alternative — `starts_at < :to AND ends_at > :from` — is + * not merely slower, it degrades as the calendar gets more interesting. A + * btree on starts_at stops approximating ends_at ordering the moment + * multi-day events exist, so the planner scans everything from the window's + * start backwards to find the ones that began earlier and are still + * running. The overlap operator answers that from one index. + * + * Ids are fetched in SQL and hydrated through the ORM, so callers still get + * entities with their events attached, and the N+1 stays fetch-joined here + * rather than in every view. + * + * @param list $calendarIds + * + * @return list + */ + public function findInRange( + UserInterface $user, + array $calendarIds, + DateTimeImmutable $from, + DateTimeImmutable $to, + bool $includeCancelled = false, + ): array { + if (0 === count($calendarIds)) { + return []; + } + + $sql = 'SELECT o.id + FROM calendar_event_occurrence o + WHERE o.usr_id = :occUserId + AND o.calendar_id IN (:occCalendarIds) + AND o.span && tsrange(:occFrom, :occTo, \'[)\')'; + + if (false === $includeCancelled) { + $sql .= ' AND o.cancelled = false'; + } + + $ids = $this->getEntityManager()->getConnection()->fetchFirstColumn( + $sql, + [ + 'occUserId' => $user->getId(), + 'occCalendarIds' => $calendarIds, + 'occFrom' => $from->format('Y-m-d H:i:s'), + 'occTo' => $to->format('Y-m-d H:i:s'), + ], + [ + 'occUserId' => ParameterType::INTEGER, + 'occCalendarIds' => ArrayParameterType::INTEGER, + 'occFrom' => ParameterType::STRING, + 'occTo' => ParameterType::STRING, + ], + ); + + if (0 === count($ids)) { + return []; + } + + return $this->createQueryBuilder('occurrence') + ->addSelect('event', 'calendar') + ->join('occurrence.event', 'event') + ->join('occurrence.calendar', 'calendar') + ->where('occurrence.id IN (:ids)') + ->setParameter('ids', array_map('intval', $ids)) + ->orderBy('occurrence.startsAt', 'ASC') + ->addOrderBy('occurrence.id', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * Extracted events only, from now forward — the "Happening Soon" query. + * + * A kind is exactly what distinguishes an extracted event from one a person + * typed, which is why that feature needs no table of its own. + * + * @param list $calendarIds + * + * @return list + */ + public function findUpcomingExtracted( + UserInterface $user, + array $calendarIds, + DateTimeImmutable $from, + DateTimeImmutable $to, + int $limit = 12, + ): array { + if (0 === count($calendarIds)) { + return []; + } + + return $this->createQueryBuilder('occurrence') + ->addSelect('event', 'calendar') + ->join('occurrence.event', 'event') + ->join('occurrence.calendar', 'calendar') + ->where('occurrence.usr = :usr') + ->andWhere('occurrence.calendar IN (:calendarIds)') + ->andWhere('occurrence.cancelled = false') + ->andWhere('event.kind IS NOT NULL') + ->andWhere('occurrence.startsAt >= :from') + ->andWhere('occurrence.startsAt < :to') + ->setParameter('usr', $user) + ->setParameter('calendarIds', $calendarIds) + ->setParameter('from', $from) + ->setParameter('to', $to) + ->orderBy('occurrence.startsAt', 'ASC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } + + /** + * Drops an event's materialised rows so the materialiser can write the new + * set. One statement rather than loading a collection to delete it: a daily + * event over the horizon is well over a thousand rows, and none of them + * have behaviour worth hydrating. + */ + public function deleteForEvent(CalendarEvent $event): void + { + $this->getEntityManager()->getConnection()->executeStatement( + 'DELETE FROM calendar_event_occurrence WHERE event_id = :eventId', + ['eventId' => $event->id], + ['eventId' => ParameterType::INTEGER], + ); + } +} diff --git a/src/Repository/Calendar/CalendarEventRepository.php b/src/Repository/Calendar/CalendarEventRepository.php new file mode 100644 index 00000000..c2b7b8bf --- /dev/null +++ b/src/Repository/Calendar/CalendarEventRepository.php @@ -0,0 +1,52 @@ + + */ +class CalendarEventRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, CalendarEvent::class); + } + + public function findOneForUser(UserInterface $user, int $id): ?CalendarEvent + { + return $this->findOneBy(['id' => $id, 'usr' => $user]); + } + + /** The identity a later message updates or cancels against. */ + public function findOneByUid(Calendar $calendar, string $uid): ?CalendarEvent + { + return $this->findOneBy(['calendar' => $calendar, 'uid' => $uid]); + } + + /** + * Recurring events whose materialised occurrences may not reach far enough + * yet — everything unbounded, plus anything ending after the horizon the + * last sweep wrote. + * + * @return list + */ + public function findNeedingHorizonExtension(DateTimeImmutable $horizonEnd): array + { + return $this->createQueryBuilder('event') + ->where('event.isRecurring = true') + ->andWhere('event.recurrenceUntil IS NULL OR event.recurrenceUntil > :horizonEnd') + ->setParameter('horizonEnd', $horizonEnd) + ->orderBy('event.id', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Repository/Calendar/CalendarRepository.php b/src/Repository/Calendar/CalendarRepository.php new file mode 100644 index 00000000..d9386ea4 --- /dev/null +++ b/src/Repository/Calendar/CalendarRepository.php @@ -0,0 +1,63 @@ + + */ +class CalendarRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Calendar::class); + } + + /** + * Every calendar a user owns, in sidebar order. + * + * @return list + */ + public function findForUser(UserInterface $user): array + { + return $this->findBy(['usr' => $user], ['sortOrder' => 'ASC', 'id' => 'ASC']); + } + + /** + * The ones a view should actually read. Kept separate from findForUser() + * because the settings page wants the hidden ones too. + * + * @return list + */ + public function findVisibleForUser(UserInterface $user): array + { + return $this->findBy( + ['usr' => $user, 'isVisible' => true], + ['sortOrder' => 'ASC', 'id' => 'ASC'], + ); + } + + public function findDefaultForUser(UserInterface $user): ?Calendar + { + return $this->findOneBy(['usr' => $user, 'isDefault' => true]); + } + + /** The calendar mail from this account lands on. */ + public function findForAccount(Account $account): ?Calendar + { + return $this->findOneBy(['account' => $account, 'role' => CalendarRole::Account]); + } + + public function findOneForUser(UserInterface $user, int $id): ?Calendar + { + return $this->findOneBy(['id' => $id, 'usr' => $user]); + } +} diff --git a/src/Repository/Calendar/EventSourceLinkRepository.php b/src/Repository/Calendar/EventSourceLinkRepository.php new file mode 100644 index 00000000..e8f5e6f7 --- /dev/null +++ b/src/Repository/Calendar/EventSourceLinkRepository.php @@ -0,0 +1,41 @@ + + */ +class EventSourceLinkRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, EventSourceLink::class); + } + + /** + * What this message put on the calendar — the "Added to …" chip in the + * thread view, and the answer to "why is this on my calendar?". + * + * @return list + */ + public function findAppliedForMessage(Message $message): array + { + return $this->createQueryBuilder('link') + ->addSelect('event', 'calendar') + ->join('link.event', 'event') + ->join('event.calendar', 'calendar') + ->where('link.message = :message') + ->andWhere('link.applied = true') + ->setParameter('message', $message) + ->orderBy('link.id', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Repository/Calendar/EventSuppressionRepository.php b/src/Repository/Calendar/EventSuppressionRepository.php new file mode 100644 index 00000000..a02b293d --- /dev/null +++ b/src/Repository/Calendar/EventSuppressionRepository.php @@ -0,0 +1,35 @@ + + */ +class EventSuppressionRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, EventSuppression::class); + } + + /** Hashes the key itself so no caller has to remember to. */ + public function isSuppressed(UserInterface $user, string $dedupKey): bool + { + return null !== $this->findOneBy([ + 'usr' => $user, + 'dedupKeyHash' => self::hash($dedupKey), + ]); + } + + public static function hash(string $dedupKey): string + { + return hash('sha256', $dedupKey); + } +} diff --git a/src/Service/Calendar/RecurrenceMaterialiser.php b/src/Service/Calendar/RecurrenceMaterialiser.php new file mode 100644 index 00000000..b4ed3954 --- /dev/null +++ b/src/Service/Calendar/RecurrenceMaterialiser.php @@ -0,0 +1,343 @@ +startsAt || null === $event->endsAt) { + return; + } + + $this->occurrences->deleteForEvent($event); + $event->occurrences->clear(); + + $rule = $this->firstRule($event); + + if (null === $rule) { + $event->isRecurring = false; + $event->recurrenceUntil = $event->endsAt; + + $this->addOccurrence($event, $event->startsAt, $event->startsAt, $event->endsAt); + + return; + } + + $event->isRecurring = true; + + $this->expand($event, $rule, $now ?? new DateTimeImmutable()); + } + + /** + * @param array $rule + */ + private function expand(CalendarEvent $event, array $rule, DateTimeImmutable $now): void + { + $rrule = $this->converter->toRrule($rule); + + if (null === $rrule) { + // An unusable rule is not a recurring event. One occurrence is + // wrong-ish; a silently empty calendar is worse. + $event->isRecurring = false; + $event->recurrenceUntil = $event->endsAt; + + $this->addOccurrence($event, $event->startsAt, $event->startsAt, $event->endsAt); + + return; + } + + $zone = $this->zoneOf($event); + $seed = $event->startsAt->setTimezone($zone); + $duration = $event->endsAt->getTimestamp() - $event->startsAt->getTimestamp(); + + $horizonEnd = $now->modify(self::HORIZON_FUTURE); + $horizonMin = $now->modify(self::HORIZON_PAST); + + $overrides = $this->overrides($event); + $written = 0; + $last = null; + $exhausted = true; + $previousId = null; + + try { + $iterator = new RRuleIterator($rrule, \DateTime::createFromInterface($seed)); + } catch (\Throwable $e) { + $this->logger->warning('Calendar: unusable recurrence rule', [ + 'eventId' => $event->id, + 'rrule' => $rrule, + 'error' => $e->getMessage(), + ]); + + $event->isRecurring = false; + $event->recurrenceUntil = $event->endsAt; + + $this->addOccurrence($event, $event->startsAt, $event->startsAt, $event->endsAt); + + return; + } + + foreach ($iterator as $current) { + if ($written >= self::MAX_OCCURRENCES) { + $exhausted = false; + + break; + } + + $recurrenceId = DateTimeImmutable::createFromInterface($current) + ->setTimezone(new DateTimeZone('UTC')); + + // A recurrence that does not move forward is not a recurrence. The + // occurrence cap alone would turn that into a thousand identical + // rows rather than a hang, which is the harder failure to notice — + // so stop on the first repeat and say so. + if (null !== $previousId && $recurrenceId <= $previousId) { + $this->logger->warning('Calendar: recurrence rule does not advance, stopping', [ + 'eventId' => $event->id, + 'rrule' => $rrule, + 'at' => $recurrenceId->format(DATE_ATOM), + ]); + + break; + } + + $previousId = $recurrenceId; + + if ($recurrenceId > $horizonEnd) { + $exhausted = false; + + break; + } + + // The past horizon skips rather than stops: a rule that started in + // 2019 still has to be walked to reach this year's instances. + if ($recurrenceId < $horizonMin) { + continue; + } + + $override = $overrides[$this->overrideKey($current)] ?? null; + + if (true === ($override['excluded'] ?? false)) { + continue; + } + + [$startsAt, $endsAt] = $this->applyOverride($override, $recurrenceId, $duration, $zone); + + $this->addOccurrence( + $event, + $recurrenceId, + $startsAt, + $endsAt, + isOverride: null !== $override, + cancelled: 'cancelled' === ($override['status'] ?? null), + ); + + $last = $endsAt; + $written++; + } + + // Null means "we stopped because we ran out of room, not because the + // rule did" — which is exactly the set the nightly sweep re-reads. + $event->recurrenceUntil = true === $exhausted ? $last : null; + } + + /** + * An override may move the instance, so its start is taken from the patch + * when present and derived from the recurrence id otherwise. + * + * @param array|null $override + * + * @return array{DateTimeImmutable, DateTimeImmutable} + */ + private function applyOverride( + ?array $override, + DateTimeImmutable $recurrenceId, + int $duration, + DateTimeZone $zone, + ): array { + $startsAt = $recurrenceId; + + if (true === is_string($override['start'] ?? null)) { + $moved = $this->parseLocal($override['start'], $zone); + + if (null !== $moved) { + $startsAt = $moved; + } + } + + return [$startsAt, $startsAt->modify(sprintf('+%d seconds', $duration))]; + } + + private function addOccurrence( + CalendarEvent $event, + DateTimeImmutable $recurrenceId, + DateTimeImmutable $startsAt, + DateTimeImmutable $endsAt, + bool $isOverride = false, + bool $cancelled = false, + ): void { + $occurrence = new CalendarEventOccurrence(); + $occurrence->event = $event; + $occurrence->calendar = $event->calendar; + $occurrence->usr = $event->usr; + $occurrence->recurrenceId = $recurrenceId; + $occurrence->startsAt = $startsAt; + $occurrence->endsAt = $endsAt; + $occurrence->isOverride = $isOverride; + $occurrence->cancelled = $cancelled; + + $this->em->persist($occurrence); + $event->occurrences->add($occurrence); + } + + /** + * JSCalendar allows several rules; RRULE allows one, and a second RRULE was + * deprecated in RFC 5545 for good reason. The first is used and the rest + * logged, rather than silently producing a union nobody asked for. + * + * @return array|null + */ + private function firstRule(CalendarEvent $event): ?array + { + $rules = $event->jscalendar['recurrenceRules'] ?? null; + + if (false === is_array($rules) || 0 === count($rules)) { + return null; + } + + if (count($rules) > 1) { + $this->logger->info('Calendar: event has multiple recurrence rules, using the first', [ + 'eventId' => $event->id, + 'count' => count($rules), + ]); + } + + $first = reset($rules); + + return is_array($first) ? $first : null; + } + + /** + * recurrenceOverrides is keyed by the instance's original local start. + * + * @return array> + */ + private function overrides(CalendarEvent $event): array + { + $overrides = $event->jscalendar['recurrenceOverrides'] ?? null; + + if (false === is_array($overrides)) { + return []; + } + + $byKey = []; + + foreach ($overrides as $key => $patch) { + if (true === is_string($key) && is_array($patch)) { + $byKey[$key] = $patch; + } + } + + return $byKey; + } + + /** The LocalDateTime form JSCalendar keys recurrenceOverrides by. */ + private function overrideKey(\DateTimeInterface $local): string + { + return $local->format('Y-m-d\TH:i:s'); + } + + private function parseLocal(string $value, DateTimeZone $zone): ?DateTimeImmutable + { + try { + return (new DateTimeImmutable($value, $zone))->setTimezone(new DateTimeZone('UTC')); + } catch (\Exception) { + return null; + } + } + + /** + * All-day events are floating and carry no zone, so they expand in UTC — + * which is what floating means here: the same wall-clock day everywhere. + */ + private function zoneOf(CalendarEvent $event): DateTimeZone + { + if (null === $event->timeZone || '' === $event->timeZone) { + return new DateTimeZone('UTC'); + } + + try { + return new DateTimeZone($event->timeZone); + } catch (\Exception) { + $this->logger->warning('Calendar: unknown time zone on event, falling back to UTC', [ + 'eventId' => $event->id, + 'timeZone' => $event->timeZone, + ]); + + return new DateTimeZone('UTC'); + } + } +} diff --git a/src/Service/Calendar/RecurrenceRuleConverter.php b/src/Service/Calendar/RecurrenceRuleConverter.php new file mode 100644 index 00000000..9ec98a5f --- /dev/null +++ b/src/Service/Calendar/RecurrenceRuleConverter.php @@ -0,0 +1,219 @@ + 'YEARLY', + 'monthly' => 'MONTHLY', + 'weekly' => 'WEEKLY', + 'daily' => 'DAILY', + 'hourly' => 'HOURLY', + ]; + + private const array WEEKDAYS = [ + 'mo' => 'MO', 'tu' => 'TU', 'we' => 'WE', 'th' => 'TH', + 'fr' => 'FR', 'sa' => 'SA', 'su' => 'SU', + ]; + + /** + * @param array $rule one JSCalendar RecurrenceRule + * + * @return string|null an RRULE value ("FREQ=WEEKLY;BYDAY=MO"), or null if + * the rule has no usable frequency + */ + public function toRrule(array $rule): ?string + { + $frequency = is_string($rule['frequency'] ?? null) + ? mb_strtolower($rule['frequency']) + : ''; + + if (false === array_key_exists($frequency, self::FREQUENCIES)) { + return null; + } + + $parts = ['FREQ=' . self::FREQUENCIES[$frequency]]; + + $interval = $rule['interval'] ?? 1; + + if (true === is_int($interval) && $interval > 1) { + $parts[] = 'INTERVAL=' . $interval; + } + + // COUNT and UNTIL are mutually exclusive in RFC 5545. JSCalendar says + // the same, but a hand-edited object can carry both; COUNT wins, + // arbitrarily but consistently, so the result is at least deterministic. + if (true === is_int($rule['count'] ?? null)) { + $parts[] = 'COUNT=' . $rule['count']; + } elseif (true === is_string($rule['until'] ?? null)) { + $until = $this->parseUntil($rule['until']); + + if (null !== $until) { + $parts[] = 'UNTIL=' . $until->format('Ymd\THis'); + } + } + + if (null !== $byDay = $this->byDay($rule['byDay'] ?? null)) { + $parts[] = 'BYDAY=' . $byDay; + } + + foreach ([ + 'byMonthDay' => 'BYMONTHDAY', + 'byYearDay' => 'BYYEARDAY', + 'byWeekNo' => 'BYWEEKNO', + 'byHour' => 'BYHOUR', + 'byMinute' => 'BYMINUTE', + 'bySecond' => 'BYSECOND', + ] as $jsKey => $icalKey) { + if (null !== $values = $this->intList($rule[$jsKey] ?? null)) { + $parts[] = $icalKey . '=' . $values; + } + } + + // byMonth is strings in JSCalendar ("1", and "5L" for a leap month), + // not ints. Leap months are Hebrew/Chinese calendar territory and RRULE + // has no way to say it, so those are dropped rather than mangled. + if (null !== $byMonth = $this->byMonth($rule['byMonth'] ?? null)) { + $parts[] = 'BYMONTH=' . $byMonth; + } + + if (null !== $bySetPos = $this->intList($rule['bySetPosition'] ?? null)) { + $parts[] = 'BYSETPOS=' . $bySetPos; + } + + $firstDay = is_string($rule['firstDayOfWeek'] ?? null) + ? mb_strtolower($rule['firstDayOfWeek']) + : ''; + + if ('' !== $firstDay && true === array_key_exists($firstDay, self::WEEKDAYS)) { + $parts[] = 'WKST=' . self::WEEKDAYS[$firstDay]; + } + + return implode(';', $parts); + } + + /** + * BYDAY carries an optional ordinal in JSCalendar's nthOfPeriod, which is + * how "the last Friday of the month" is said: {"day":"fr","nthOfPeriod":-1} + * becomes -1FR. + */ + private function byDay(mixed $byDay): ?string + { + if (false === is_array($byDay) || 0 === count($byDay)) { + return null; + } + + $days = []; + + foreach ($byDay as $entry) { + if (false === is_array($entry)) { + continue; + } + + $day = is_string($entry['day'] ?? null) ? mb_strtolower($entry['day']) : ''; + + if (false === array_key_exists($day, self::WEEKDAYS)) { + continue; + } + + $nth = $entry['nthOfPeriod'] ?? null; + + $days[] = (true === is_int($nth) && 0 !== $nth) + ? $nth . self::WEEKDAYS[$day] + : self::WEEKDAYS[$day]; + } + + return 0 === count($days) ? null : implode(',', $days); + } + + private function byMonth(mixed $byMonth): ?string + { + if (false === is_array($byMonth) || 0 === count($byMonth)) { + return null; + } + + $months = []; + + foreach ($byMonth as $month) { + if (true === is_int($month)) { + $months[] = (string) $month; + + continue; + } + + // "5L" is a leap month; RRULE cannot express it. + if (true === is_string($month) && 1 === preg_match('/^\d{1,2}$/', $month)) { + $months[] = $month; + } + } + + return 0 === count($months) ? null : implode(',', $months); + } + + private function intList(mixed $values): ?string + { + if (false === is_array($values) || 0 === count($values)) { + return null; + } + + $ints = array_values(array_filter($values, 'is_int')); + + return 0 === count($ints) ? null : implode(',', $ints); + } + + /** + * JSCalendar UNTIL is a LocalDateTime — no zone, no trailing Z. It is read + * in the event's own zone by the caller, so this only has to parse it. + */ + private function parseUntil(string $until): ?DateTimeImmutable + { + $parsed = DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s', $until, new \DateTimeZone('UTC')); + + if (false !== $parsed) { + return $parsed; + } + + try { + return new DateTimeImmutable($until, new \DateTimeZone('UTC')); + } catch (\Exception) { + return null; + } + } +} diff --git a/tests/Repository/Calendar/CalendarEventOccurrenceRepositoryTest.php b/tests/Repository/Calendar/CalendarEventOccurrenceRepositoryTest.php new file mode 100644 index 00000000..43b76825 --- /dev/null +++ b/tests/Repository/Calendar/CalendarEventOccurrenceRepositoryTest.php @@ -0,0 +1,230 @@ +em = $container->get(EntityManagerInterface::class); + $this->connection = $container->get(Connection::class); + $this->repository = $container->get(CalendarEventOccurrenceRepository::class); + + $this->connection->beginTransaction(); + $this->calendar = $this->seedCalendar(); + } + + protected function tearDown(): void + { + if (true === $this->connection->isTransactionActive()) { + $this->connection->rollBack(); + } + + parent::tearDown(); + } + + public function testAnEventInsideTheWindowIsFound(): void + { + $this->occurrence('2026-04-15 09:00', '2026-04-15 10:00'); + + self::assertCount(1, $this->range('2026-04-13 00:00', '2026-04-20 00:00')); + } + + /** + * The one a btree on starts_at gets wrong: it began before the window + * opened and has not finished. Overlap is the question, not containment. + */ + public function testMultiDayEventStartingBeforeTheWindow(): void + { + $this->occurrence('2026-04-08 09:00', '2026-04-16 17:00'); + + self::assertCount( + 1, + $this->range('2026-04-13 00:00', '2026-04-20 00:00'), + 'a conference running through the week is in the week', + ); + } + + /** tsrange is half-open, so an event ending exactly at the boundary is out. */ + public function testTheWindowIsHalfOpen(): void + { + $this->occurrence('2026-04-12 22:00', '2026-04-13 00:00'); + $this->occurrence('2026-04-19 23:00', '2026-04-20 01:00'); + + $found = $this->range('2026-04-13 00:00', '2026-04-20 00:00'); + + self::assertCount(1, $found, 'the leading edge is excluded, the trailing one overlaps'); + self::assertSame('2026-04-19 23:00', $found[0]->startsAt->format('Y-m-d H:i')); + } + + public function testEventsOutsideTheWindowAreNotFound(): void + { + $this->occurrence('2026-01-05 09:00', '2026-01-05 10:00'); + $this->occurrence('2026-09-05 09:00', '2026-09-05 10:00'); + + self::assertCount(0, $this->range('2026-04-13 00:00', '2026-04-20 00:00')); + } + + /** A cancelled instance is stored, and hidden unless it is asked for. */ + public function testCancelledOccurrencesAreExcludedByDefault(): void + { + $this->occurrence('2026-04-15 09:00', '2026-04-15 10:00', cancelled: true); + + self::assertCount(0, $this->range('2026-04-13 00:00', '2026-04-20 00:00')); + self::assertCount(1, $this->range('2026-04-13 00:00', '2026-04-20 00:00', includeCancelled: true)); + } + + /** Someone else's calendar is not in the list, so it cannot come back. */ + public function testAnEmptyCalendarListReturnsNothing(): void + { + $this->occurrence('2026-04-15 09:00', '2026-04-15 10:00'); + + self::assertCount(0, $this->repository->findInRange( + $this->user, + [], + new DateTimeImmutable('2026-04-13 00:00', new DateTimeZone('UTC')), + new DateTimeImmutable('2026-04-20 00:00', new DateTimeZone('UTC')), + )); + } + + public function testResultsComeBackInStartOrder(): void + { + $this->occurrence('2026-04-17 09:00', '2026-04-17 10:00'); + $this->occurrence('2026-04-14 09:00', '2026-04-14 10:00'); + $this->occurrence('2026-04-16 09:00', '2026-04-16 10:00'); + + $starts = array_map( + static fn (CalendarEventOccurrence $o): string => $o->startsAt->format('Y-m-d'), + $this->range('2026-04-13 00:00', '2026-04-20 00:00'), + ); + + self::assertSame(['2026-04-14', '2026-04-16', '2026-04-17'], $starts); + } + + /** + * "Happening Soon" is a window over the same table filtered on kind, which + * is what makes a second table for it unnecessary. + */ + public function testUpcomingExtractedIgnoresHandMadeEvents(): void + { + $this->occurrence('2026-04-15 09:00', '2026-04-15 10:00'); + $this->occurrence('2026-04-16 09:00', '2026-04-16 10:00', kind: ExtractionKind::Delivery); + + $found = $this->repository->findUpcomingExtracted( + $this->user, + [(int) $this->calendar->id], + new DateTimeImmutable('2026-04-13 00:00', new DateTimeZone('UTC')), + new DateTimeImmutable('2026-04-20 00:00', new DateTimeZone('UTC')), + ); + + self::assertCount(1, $found); + self::assertSame(ExtractionKind::Delivery, $found[0]->event->kind); + } + + // ── Fixtures ────────────────────────────────────────────────────────── + + /** + * @return list + */ + private function range(string $from, string $to, bool $includeCancelled = false): array + { + return $this->repository->findInRange( + $this->user, + [(int) $this->calendar->id], + new DateTimeImmutable($from, new DateTimeZone('UTC')), + new DateTimeImmutable($to, new DateTimeZone('UTC')), + $includeCancelled, + ); + } + + private function occurrence( + string $startsAt, + string $endsAt, + bool $cancelled = false, + ?ExtractionKind $kind = null, + ): CalendarEventOccurrence { + $utc = new DateTimeZone('UTC'); + $start = new DateTimeImmutable($startsAt, $utc); + $end = new DateTimeImmutable($endsAt, $utc); + + $event = new CalendarEvent(); + $event->calendar = $this->calendar; + $event->usr = $this->user; + $event->uid = uniqid('range-', true) . '@plmail.test'; + $event->title = 'Range fixture'; + $event->startsAt = $start; + $event->endsAt = $end; + $event->kind = $kind; + $event->jscalendar = ['@type' => 'Event', 'title' => 'Range fixture']; + $this->em->persist($event); + + $occurrence = new CalendarEventOccurrence(); + $occurrence->event = $event; + $occurrence->calendar = $this->calendar; + $occurrence->usr = $this->user; + $occurrence->recurrenceId = $start; + $occurrence->startsAt = $start; + $occurrence->endsAt = $end; + $occurrence->cancelled = $cancelled; + $this->em->persist($occurrence); + + $this->em->flush(); + + return $occurrence; + } + + private function seedCalendar(): Calendar + { + $user = new User(); + $user + ->setEmail('range-' . uniqid('', true) . '@example.test') + ->setNameFirst('Range') + ->setNameLast('Fixture') + ->setRoles(['ROLE_USER']) + ->setPassword('x'); + $this->em->persist($user); + $this->user = $user; + + $calendar = new Calendar(); + $calendar->usr = $user; + $calendar->name = 'Fixture'; + $calendar->timeZone = 'UTC'; + $this->em->persist($calendar); + $this->em->flush(); + + return $calendar; + } +} diff --git a/tests/Service/Calendar/RecurrenceMaterialiserTest.php b/tests/Service/Calendar/RecurrenceMaterialiserTest.php new file mode 100644 index 00000000..ce7d5b56 --- /dev/null +++ b/tests/Service/Calendar/RecurrenceMaterialiserTest.php @@ -0,0 +1,431 @@ +em = $container->get(EntityManagerInterface::class); + $this->connection = $container->get(Connection::class); + + // Built by hand rather than pulled from the container: the converter + // has no dependencies worth wiring, and both services are inlined at + // compile time whenever the only caller is another service. + $this->materialiser = new RecurrenceMaterialiser( + $container->get(CalendarEventOccurrenceRepository::class), + new RecurrenceRuleConverter(), + $this->em, + $container->get(LoggerInterface::class), + ); + + $this->connection->beginTransaction(); + $this->calendar = $this->seedCalendar(); + } + + protected function tearDown(): void + { + if (true === $this->connection->isTransactionActive()) { + $this->connection->rollBack(); + } + + parent::tearDown(); + } + + /** A one-off still gets a row, so reads have exactly one code path. */ + public function testANonRecurringEventGetsASingleOccurrence(): void + { + $event = $this->event('2026-03-10 09:00', '2026-03-10 10:00'); + + $this->materialise($event); + + self::assertCount(1, $event->occurrences); + self::assertFalse($event->isRecurring); + self::assertEquals($event->endsAt, $event->recurrenceUntil); + } + + /** + * The one that matters. Berlin goes to summer time on 2026-03-29, so a + * weekly 09:00 local meeting is 08:00 UTC before it and 07:00 UTC after. + */ + public function testWeeklyExpansionHoldsLocalTimeAcrossADstTransition(): void + { + $event = $this->event( + '2026-03-23 09:00', + '2026-03-23 10:00', + timeZone: 'Europe/Berlin', + rule: ['frequency' => 'weekly', 'count' => 4], + ); + + $this->materialise($event, now: '2026-03-01 00:00'); + + $starts = $this->startsInZone($event, 'Europe/Berlin'); + + self::assertSame( + ['2026-03-23 09:00', '2026-03-30 09:00', '2026-04-06 09:00', '2026-04-13 09:00'], + $starts, + 'local wall-clock time must not drift', + ); + + $utc = $this->startsInZone($event, 'UTC'); + + self::assertSame('2026-03-23 08:00', $utc[0], 'winter time is UTC+1'); + self::assertSame('2026-03-30 07:00', $utc[1], 'summer time is UTC+2'); + } + + /** "The last Friday of the month" — the ordinal BYDAY conversion. */ + public function testMonthlyOnTheLastFriday(): void + { + $event = $this->event( + '2026-01-30 15:00', + '2026-01-30 16:00', + rule: [ + 'frequency' => 'monthly', + 'count' => 3, + 'byDay' => [['day' => 'fr', 'nthOfPeriod' => -1]], + ], + ); + + $this->materialise($event, now: '2026-01-01 00:00'); + + self::assertSame( + ['2026-01-30 15:00', '2026-02-27 15:00', '2026-03-27 15:00'], + $this->startsInZone($event, 'UTC'), + ); + } + + public function testUntilStopsTheSeries(): void + { + $event = $this->event( + '2026-05-04 08:00', + '2026-05-04 08:30', + rule: ['frequency' => 'daily', 'until' => '2026-05-07T00:00:00'], + ); + + $this->materialise($event, now: '2026-05-01 00:00'); + + self::assertSame( + ['2026-05-04 08:00', '2026-05-05 08:00', '2026-05-06 08:00'], + $this->startsInZone($event, 'UTC'), + ); + self::assertNotNull($event->recurrenceUntil, 'a bounded rule has a known end'); + } + + public function testIntervalIsHonoured(): void + { + $event = $this->event( + '2026-06-01 12:00', + '2026-06-01 13:00', + rule: ['frequency' => 'weekly', 'interval' => 2, 'count' => 3], + ); + + $this->materialise($event, now: '2026-06-01 00:00'); + + self::assertSame( + ['2026-06-01 12:00', '2026-06-15 12:00', '2026-06-29 12:00'], + $this->startsInZone($event, 'UTC'), + ); + } + + /** + * No UNTIL, no COUNT. Stops at the horizon, and says so by leaving + * recurrenceUntil null — which is how the nightly sweep finds it again. + */ + public function testAnUnboundedRuleStopsAtTheHorizonAndStaysExtendable(): void + { + $event = $this->event( + '2026-01-01 06:00', + '2026-01-01 06:30', + rule: ['frequency' => 'daily'], + ); + + $this->materialise($event, now: '2026-01-01 00:00'); + + $count = count($event->occurrences); + + self::assertGreaterThan(700, $count, 'roughly two years of daily'); + self::assertLessThanOrEqual(RecurrenceMaterialiser::MAX_OCCURRENCES, $count); + self::assertNull($event->recurrenceUntil, 'unfinished, so the sweep must revisit it'); + } + + /** + * Hourly forever is ~17,500 instances inside the horizon. The cap is what + * keeps one event from being most of the table. + */ + public function testAnHourlyRuleIsCappedRatherThanFillingTheHorizon(): void + { + $event = $this->event( + '2026-01-01 00:00', + '2026-01-01 00:30', + rule: ['frequency' => 'hourly'], + ); + + $this->materialise($event, now: '2026-01-01 00:00'); + + self::assertCount(RecurrenceMaterialiser::MAX_OCCURRENCES, $event->occurrences); + self::assertNull($event->recurrenceUntil, 'capped, so the sweep must revisit it'); + } + + /** + * FREQ=SECONDLY is legal in both RFCs and sabre accepts it at validation, + * but its iterator has no branch to advance on — it yields the same instant + * forever. The cap alone would turn that into a thousand identical rows, + * which looks like it worked. So the converter refuses the frequency and + * the event degrades to a single visible occurrence instead. + */ + public function testASecondlyRuleDegradesRatherThanRepeatingOneInstant(): void + { + $event = $this->event( + '2026-01-01 00:00', + '2026-01-01 00:01', + rule: ['frequency' => 'secondly'], + ); + + $this->materialise($event, now: '2026-01-01 00:00'); + + self::assertCount(1, $event->occurrences); + self::assertFalse($event->isRecurring); + } + + /** recurrenceOverrides with "excluded" is JSCalendar's EXDATE. */ + public function testAnExcludedInstanceIsSkipped(): void + { + $event = $this->event( + '2026-02-02 10:00', + '2026-02-02 11:00', + rule: ['frequency' => 'daily', 'count' => 4], + overrides: ['2026-02-03T10:00:00' => ['excluded' => true]], + ); + + $this->materialise($event, now: '2026-02-01 00:00'); + + self::assertSame( + ['2026-02-02 10:00', '2026-02-04 10:00', '2026-02-05 10:00'], + $this->startsInZone($event, 'UTC'), + ); + } + + /** + * A moved instance keeps its ORIGINAL start as recurrenceId. That is the + * only stable way to say "the one that was meant to be on the 3rd" once it + * has been dragged to the 5th, and it is what a later update matches on. + */ + public function testAnOverrideMovesOneInstanceButKeepsItsIdentity(): void + { + $event = $this->event( + '2026-02-02 10:00', + '2026-02-02 11:00', + rule: ['frequency' => 'daily', 'count' => 3], + overrides: ['2026-02-03T10:00:00' => ['start' => '2026-02-03T16:30:00']], + ); + + $this->materialise($event, now: '2026-02-01 00:00'); + + self::assertSame( + ['2026-02-02 10:00', '2026-02-03 16:30', '2026-02-04 10:00'], + $this->startsInZone($event, 'UTC'), + ); + + $moved = $this->occurrenceAt($event, '2026-02-03 16:30'); + + self::assertTrue($moved->isOverride); + self::assertSame( + '2026-02-03 10:00', + $moved->recurrenceId->format('Y-m-d H:i'), + 'identity is where it was meant to be, not where it went', + ); + self::assertSame(3600, $moved->endsAt->getTimestamp() - $moved->startsAt->getTimestamp()); + } + + /** Cancelling one instance keeps the row, struck through rather than gone. */ + public function testACancelledInstanceIsKeptAndFlagged(): void + { + $event = $this->event( + '2026-02-02 10:00', + '2026-02-02 11:00', + rule: ['frequency' => 'daily', 'count' => 3], + overrides: ['2026-02-03T10:00:00' => ['status' => 'cancelled']], + ); + + $this->materialise($event, now: '2026-02-01 00:00'); + + self::assertCount(3, $event->occurrences); + self::assertTrue($this->occurrenceAt($event, '2026-02-03 10:00')->cancelled); + } + + /** Re-materialising replaces, never appends. */ + public function testMaterialisingTwiceDoesNotDuplicate(): void + { + $event = $this->event( + '2026-07-01 09:00', + '2026-07-01 10:00', + rule: ['frequency' => 'daily', 'count' => 5], + ); + + $this->materialise($event, now: '2026-07-01 00:00'); + $this->materialise($event, now: '2026-07-01 00:00'); + + self::assertCount(5, $event->occurrences); + self::assertSame(5, (int) $this->connection->fetchOne( + 'SELECT COUNT(*) FROM calendar_event_occurrence WHERE event_id = ?', + [$event->id], + )); + } + + /** + * A rule nothing can expand — a frequency that is not one — degrades to a + * single occurrence. An empty calendar would be the silent failure. + */ + public function testAnUnusableRuleFallsBackToOneOccurrence(): void + { + $event = $this->event( + '2026-08-01 09:00', + '2026-08-01 10:00', + rule: ['frequency' => 'fortnightly'], + ); + + $this->materialise($event, now: '2026-08-01 00:00'); + + self::assertCount(1, $event->occurrences); + self::assertFalse($event->isRecurring); + } + + // ── Fixtures ────────────────────────────────────────────────────────── + + private function materialise(CalendarEvent $event, ?string $now = null): void + { + $this->materialiser->materialise( + $event, + null === $now ? null : new DateTimeImmutable($now, new DateTimeZone('UTC')), + ); + + $this->em->flush(); + } + + /** + * @return list occurrence starts, rendered in one zone, in order + */ + private function startsInZone(CalendarEvent $event, string $zone): array + { + $starts = []; + + foreach ($event->occurrences as $occurrence) { + $starts[] = $occurrence->startsAt + ->setTimezone(new DateTimeZone($zone)) + ->format('Y-m-d H:i'); + } + + sort($starts); + + return $starts; + } + + private function occurrenceAt(CalendarEvent $event, string $utcStart): object + { + foreach ($event->occurrences as $occurrence) { + if ($occurrence->startsAt->format('Y-m-d H:i') === $utcStart) { + return $occurrence; + } + } + + self::fail(sprintf('No occurrence starting at %s', $utcStart)); + } + + /** + * @param array|null $rule + * @param array>|null $overrides + */ + private function event( + string $startsAt, + string $endsAt, + ?string $timeZone = null, + ?array $rule = null, + ?array $overrides = null, + ): CalendarEvent { + $zone = new DateTimeZone($timeZone ?? 'UTC'); + + $jscalendar = ['@type' => 'Event', 'title' => 'Fixture']; + + if (null !== $rule) { + $jscalendar['recurrenceRules'] = [$rule]; + } + + if (null !== $overrides) { + $jscalendar['recurrenceOverrides'] = $overrides; + } + + $event = new CalendarEvent(); + $event->calendar = $this->calendar; + $event->usr = $this->user; + $event->uid = uniqid('fixture-', true) . '@plmail.test'; + $event->title = 'Fixture'; + $event->timeZone = $timeZone; + $event->jscalendar = $jscalendar; + $event->startsAt = (new DateTimeImmutable($startsAt, $zone))->setTimezone(new DateTimeZone('UTC')); + $event->endsAt = (new DateTimeImmutable($endsAt, $zone))->setTimezone(new DateTimeZone('UTC')); + + $this->em->persist($event); + $this->em->flush(); + + return $event; + } + + private function seedCalendar(): Calendar + { + $user = new User(); + $user + ->setEmail('recurrence-' . uniqid('', true) . '@example.test') + ->setNameFirst('Recurrence') + ->setNameLast('Fixture') + ->setRoles(['ROLE_USER']) + ->setPassword('x'); + $this->em->persist($user); + $this->user = $user; + + $calendar = new Calendar(); + $calendar->usr = $user; + $calendar->name = 'Fixture'; + $calendar->timeZone = 'UTC'; + + $this->em->persist($calendar); + $this->em->flush(); + + return $calendar; + } +}