From 8c0198b2ddec87df104361d0ac6d028ea1df10d0 Mon Sep 17 00:00:00 2001 From: XiovV Date: Thu, 10 Sep 2026 13:40:40 +0200 Subject: [PATCH 01/33] google calendar integration bug fixes --- CONTEXT.md | 4 + ...back-that-does-not-reach-google-says-so.md | 53 ++++++ .../00006_writeback_skipped_status.sql | 102 ++++++++++++ server/internal/outbox/worker.go | 40 ++++- server/internal/outbox/worker_test.go | 97 +++++++++++ server/internal/repository/outbox.go | 22 +++ .../internal/service/connection_writeback.go | 157 +++++++++++++----- .../service/connection_writeback_test.go | 104 +++++++++++- .../event_create_delete_writeback_test.go | 8 +- server/internal/service/google.go | 154 ++++++++++++----- .../internal/service/google_writeback_test.go | 103 ++++++++++++ src/components/layout/CalendarList.test.tsx | 49 +++++- src/components/layout/CalendarList.tsx | 110 +++++++++--- src/lib/calendar.ts | 9 +- src/settings/CalendarPickerModal.test.tsx | 62 +++++++ src/settings/CalendarPickerModal.tsx | 15 +- .../DisconnectConnectionModal.test.tsx | 80 +++++++++ src/settings/DisconnectConnectionModal.tsx | 16 ++ 18 files changed, 1060 insertions(+), 125 deletions(-) create mode 100644 docs/adr/0079-a-write-back-that-does-not-reach-google-says-so.md create mode 100644 server/internal/db/migrations/00006_writeback_skipped_status.sql create mode 100644 src/settings/DisconnectConnectionModal.test.tsx diff --git a/CONTEXT.md b/CONTEXT.md index 2211771..1d48e5b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -323,6 +323,10 @@ _Avoid_: full sync, initial sync, resync The Refresh mode that reconciles only what a Provider reports as changed since a cursor. Absence means *unchanged*, so deletions are applied only where the Provider states one explicitly, and tombstone-by-absence is structurally unreachable rather than merely switched off. The distinction is not a nicety: applying Full Refresh's rules to delta input deletes an entire Calendar. See ADR-0053. _Avoid_: incremental sync, partial refresh +**Write-back**: +The outbound counterpart to Refresh: one change made here — an Event created, edited or deleted — carried to the Provider that a Linked Calendar mirrors. Only ever on a **writable** Linked Calendar, and only ever the fields this app models, so what the Provider holds beyond them survives a change made here untouched. Queued rather than performed as part of the change itself: the local edit succeeds whether or not the Provider is reachable, which is the whole point on an instance someone runs precisely so their calendar does not depend on somebody else's uptime — and the reason a Write-back can be outstanding, and can ultimately fail, long after the edit that caused it looked complete. Deliberately not called sync, for the same reason Refresh is not: Sync in this repo means CalDAV's two-way exchange. See ADR-0075, ADR-0077, ADR-0078, ADR-0079. +_Avoid_: push, sync, export, two-way sync, upload + **External UID**: The foreign identifier a Refresh matches a series on — a feed's iCalendar `UID`, or a Provider's own event id — stored alongside the Event rather than as its id. The id stays a minted UUID, so a Calendar with a Source keeps the `{masterId}.ics` shape ADR-0025 requires and a 200-character foreign id never reaches a URL. The narrow reopening of ADR-0030's discard-the-foreign-UID rule, and the reason a Refresh can leave an unchanged series untouched instead of rewriting the whole Calendar. See ADR-0033. _Avoid_: source UID, foreign ID, original UID diff --git a/docs/adr/0079-a-write-back-that-does-not-reach-google-says-so.md b/docs/adr/0079-a-write-back-that-does-not-reach-google-says-so.md new file mode 100644 index 0000000..bae3269 --- /dev/null +++ b/docs/adr/0079-a-write-back-that-does-not-reach-google-says-so.md @@ -0,0 +1,53 @@ +# A Write-back that does not reach Google says so, quickly and specifically + +Status: accepted — amends ADR-0075 (Write-back's failure surface) and ADR-0060 (the outbox's statuses) + +Three rules, one posture. A push that Google rejects **fails on the first response** rather than walking the backoff schedule; a push that was **never dispatched** is recorded as `skipped`, not `sent`; and a failed call **keeps Google's own explanation** instead of discarding it. + +ADR-0075 built Write-back's failure surface on the premise that a push either lands or is visibly refused: *"a pending push needs no UI; it lands in seconds"*, with a per-Event marker and the Connection's needs-attention state for the permanent case. Every part of that was implemented. None of it fired, because all three rules above were the other way round, and each one independently hid the same fault. + +## What the absence of these rules cost + +Every Write-back this app had ever sent failed with **400**. The patch body carried an empty `date` beside a populated `dateTime` — a pair Google treats as mutually exclusive — because the encode path shared the decode struct, which has no `omitempty`. That is an ordinary bug, fixed in one struct, and it is not what this ADR is about. + +What this ADR is about is that the bug survived in production while three separate mechanisms designed to surface it stayed silent: + +- **The 400 was retried as though it were an outage.** Only 412 and a dead grant were classified; everything else fell through to 10s / 30s / 2m / 10m. A request that is guaranteed to be rejected identically five times consumed thirteen minutes before its Event could show a marker — and a server restart mid-schedule left the row `pending` forever, where the marker never arrived at all. +- **A push that was never dispatched was recorded as `sent`.** The "nothing left to push" paths returned a bare `nil`, which the Worker cannot distinguish from delivery. The outbox therefore contained rows claiming Google had accepted an edit it had never been shown — beside a stale `last_error` from an earlier attempt, which read as a contradiction and was in fact two different truths about two different attempts. +- **Google's explanation was thrown away.** Every call drained its failed response into `io.Discard` beneath a comment asserting *"the response carries nothing we need on failure"*. For a 4xx that is precisely backwards: the body names the offending field. `last_error` said `status 400` and nothing more, so the one artefact that should have ended the investigation instead started it. + +None of these is severe alone. Together they made a total, permanent, every-single-write failure look exactly like nothing happening. + +## The four statuses + +| status | meaning | +| --- | --- | +| `pending` | queued, or waiting out a backoff | +| `sent` | dispatched, and Google accepted it | +| `skipped` | terminal; never dispatched, with the reason in `last_error` | +| `failed` | dispatched, and permanently rejected | + +`skipped` is terminal like `sent` — the Worker does not retry it, because the state that produced it does not resolve on its own — and, unlike `failed`, raises no per-Event marker of its own. Most skips are races the User caused: they deleted the Event, they disconnected the account, they let a Refresh flip the Calendar read-only. The two that do leave the User's intent unfulfilled (an undeliverable DELETE or CANCEL_INSTANCE) already raise the Source's needs-attention state before skipping, exactly as ADR-0077 required; the status change does not weaken that, it stops the row from also claiming delivery. + +`sent_at` stays NULL on a skip. A timestamp there would reintroduce the ambiguity the status exists to remove. + +**The alternative was to keep `sent` and write the reason into `last_error`.** It needed no migration and would have fixed the misleading *row*. It was declined because it does not fix the misleading *query*: "did my edits reach Google?" is answered by `status`, and every tool, every future reader, and every one of these investigations starts there. ADR-0048 makes the schema cost near zero while this project is pre-release, which is the moment to spend it. + +## Failing fast on 4xx + +400, 401, 403 and 404 are marked permanent on the first response, via an `ErrPermanent` sentinel the Sender wraps and the Worker checks before its retry arithmetic. Three statuses stay on the retry path because they describe a moment rather than the request: **408**, **429** (where waiting is exactly the remedy), and **412**, which SendWriteBack's own bounded refetch loop owns and which must not be reclassified underneath it. 5xx and transport errors are untouched — those are the outage backoff was built for. + +**The cost is stated plainly, because it is how this trade goes wrong.** A 403 raised by an ACL change that is reverted a minute later now fails permanently where it might once have retried into a success. Editing the Event re-enqueues the push (ADR-0077), which is the recovery. That is a better default than every genuinely hopeless push holding its Event's marker back for thirteen minutes, because the hopeless case is the common one and the transient-403 case is the rare one. + +## Keeping the Provider's reason + +A failed response's body is read to a bounded prefix (512 bytes), collapsed to a single line, and carried on the error — at **every** Google call site, not only the one that bit us. The bound is not about Google, whose error JSON is small; it is about a proxy or captive portal answering with an HTML page that would otherwise flood `last_error` and a grid tooltip. + +Logging the body instead of storing it was the alternative, and was declined for the reason this whole ADR exists: the outbox row is where someone actually looks, and a row that cannot explain itself sends them to the code. + +## Consequences + +- **The bug class this closes is "a mechanism that reports nothing is indistinguishable from a system with nothing to report."** All three rules are instances of it. It is worth naming, because the next integration will have its own. +- **Tests must assert what is sent, not only what is not sent.** ADR-0075's suite is a thorough set of *negative* assertions — no attendees, no `conferenceData`, no visibility, no unexpected top-level key — and it was airtight. It never descended into `start`/`end`, so a body that was well-formed at the Go type level and invalid on the wire passed everything. Golden marshalled bodies, one per encoding shape, now pin the bytes; the negative assertions remain, because they guard a different thing. +- **A type serving both encode and decode will eventually satisfy neither.** The decode side wants every field present and bare; the encode side must send exactly one of a mutually-exclusive pair. That disagreement is not visible in the struct, only on the wire. The two are now separate types, and the encode one enforces its invariant in `MarshalJSON` so no call site can opt out — the same standard ADR-0075 already set for the field-scoped patch. +- **Historical rows are not rewritten.** A past `sent` that was really a skip stays `sent`; inventing a reason for it would be worse than leaving it. The distinction begins here. diff --git a/server/internal/db/migrations/00006_writeback_skipped_status.sql b/server/internal/db/migrations/00006_writeback_skipped_status.sql new file mode 100644 index 0000000..6c40d78 --- /dev/null +++ b/server/internal/db/migrations/00006_writeback_skipped_status.sql @@ -0,0 +1,102 @@ +-- +goose Up +-- A Write-back that was never dispatched is 'skipped', not 'sent' (ADR-0079). +-- +-- SendWriteBack has several "nothing left to push" paths that end a message +-- without ever calling Google: the Event was deleted after the push was queued, +-- the Calendar stopped being a writable Linked Calendar, its Connection was +-- removed, or a PATCH turned out to name an Event that never reached the +-- Provider at all. Each returned a bare nil, which the Worker recorded as +-- 'sent' — a claim of delivery attached to a request that never left the +-- building, sitting beside whatever last_error a previous attempt had left. +-- +-- That made the one question worth asking of this table — "did my edit actually +-- reach Google?" — unanswerable from the row. The four statuses now mean: +-- +-- pending queued, or waiting out a backoff +-- sent dispatched, and Google accepted it +-- skipped terminal; never dispatched, with the reason in last_error +-- failed dispatched, and permanently rejected +-- +-- 'skipped' is terminal like 'sent': the Worker does not retry it, because the +-- state that produced it will not resolve on its own. Unlike 'failed' it raises +-- no per-Event marker of its own — most skips are races the User caused +-- (deleting the Event, disconnecting the account), and the ones that are not +-- have already raised the Source's needs-attention state before returning. +-- +-- Existing rows are left alone. A historical 'sent' that was really a skip is +-- indistinguishable now, and inventing a reason for it would be worse than +-- leaving it: from here on the distinction is recorded, and before here it was +-- not. +-- +-- SQLite cannot alter a CHECK in place, so this rebuilds the table wholesale +-- (ADR-0048's append-only era), copying every row forward unchanged — the only +-- change is the widened status CHECK. +CREATE TABLE outbox_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL, + recipient_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + recipient_email TEXT COLLATE NOCASE, + actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + kind TEXT NOT NULL DEFAULT 'mail' CHECK (kind IN ('mail', 'writeback')), + method TEXT NOT NULL DEFAULT 'REQUEST' CHECK (method IN ('REQUEST', 'CANCEL', 'PATCH', 'POST', 'DELETE', 'INSTANCE', 'CANCEL_INSTANCE')), + snapshot TEXT, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'skipped', 'failed')), + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_error TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + sent_at TIMESTAMP, + CHECK ( + (kind = 'mail' AND method IN ('REQUEST', 'CANCEL') AND (recipient_user_id IS NULL) <> (recipient_email IS NULL)) + OR + (kind = 'writeback' AND method IN ('PATCH', 'POST', 'DELETE', 'INSTANCE', 'CANCEL_INSTANCE') AND recipient_user_id IS NULL AND recipient_email IS NULL) + ) +); + +INSERT INTO outbox_new (id, event_id, recipient_user_id, recipient_email, actor_user_id, kind, method, snapshot, status, attempts, next_attempt_at, last_error, created_at, sent_at) +SELECT id, event_id, recipient_user_id, recipient_email, actor_user_id, kind, method, snapshot, status, attempts, next_attempt_at, last_error, created_at, sent_at +FROM outbox; + +DROP TABLE outbox; +ALTER TABLE outbox_new RENAME TO outbox; + +CREATE INDEX idx_outbox_status_id ON outbox(status, id); +CREATE INDEX idx_outbox_actor_created ON outbox(actor_user_id, created_at); + +-- +goose Down +-- 'skipped' rows fold back into 'sent', the status they carried before this +-- migration existed. The reason survives in last_error; only the distinction is +-- lost, which is precisely what the Up half added. +CREATE TABLE outbox_old ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL, + recipient_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + recipient_email TEXT COLLATE NOCASE, + actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + kind TEXT NOT NULL DEFAULT 'mail' CHECK (kind IN ('mail', 'writeback')), + method TEXT NOT NULL DEFAULT 'REQUEST' CHECK (method IN ('REQUEST', 'CANCEL', 'PATCH', 'POST', 'DELETE', 'INSTANCE', 'CANCEL_INSTANCE')), + snapshot TEXT, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')), + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_error TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + sent_at TIMESTAMP, + CHECK ( + (kind = 'mail' AND method IN ('REQUEST', 'CANCEL') AND (recipient_user_id IS NULL) <> (recipient_email IS NULL)) + OR + (kind = 'writeback' AND method IN ('PATCH', 'POST', 'DELETE', 'INSTANCE', 'CANCEL_INSTANCE') AND recipient_user_id IS NULL AND recipient_email IS NULL) + ) +); + +INSERT INTO outbox_old (id, event_id, recipient_user_id, recipient_email, actor_user_id, kind, method, snapshot, status, attempts, next_attempt_at, last_error, created_at, sent_at) +SELECT id, event_id, recipient_user_id, recipient_email, actor_user_id, kind, method, snapshot, + CASE status WHEN 'skipped' THEN 'sent' ELSE status END, + attempts, next_attempt_at, last_error, created_at, sent_at +FROM outbox; + +DROP TABLE outbox; +ALTER TABLE outbox_old RENAME TO outbox; + +CREATE INDEX idx_outbox_status_id ON outbox(status, id); +CREATE INDEX idx_outbox_actor_created ON outbox(actor_user_id, created_at); diff --git a/server/internal/outbox/worker.go b/server/internal/outbox/worker.go index 2664577..f4fed6e 100644 --- a/server/internal/outbox/worker.go +++ b/server/internal/outbox/worker.go @@ -11,6 +11,7 @@ package outbox import ( "context" + "errors" "fmt" "log" "strings" @@ -19,11 +20,37 @@ import ( "github.com/XiovV/calich/server/internal/repository" ) +// ErrPermanent marks a Send failure that no retry can fix, so Tick stops +// immediately instead of walking the backoff schedule. A Sender wraps it +// around a failure it has classified as structural — a Provider rejecting the +// request itself rather than being briefly unavailable. +// +// Backoff exists to outlast a transient outage. Spending it on a request that +// is *guaranteed* to be rejected the same way five times costs the only thing +// that matters here: a permanently failed Write-back has no user-visible +// marker until the message reaches its terminal state, so retrying a hopeless +// push silently delays the badge that tells someone their edit never landed — +// and a restart mid-schedule strands the row pending, where the badge never +// arrives at all. +var ErrPermanent = errors.New("permanent send failure") + +// ErrSkipped marks a Send that ended without dispatching anything — the work it +// described no longer exists or no longer applies (ADR-0079). Tick records it +// as OutboxStatusSkipped with the wrapped reason, never as Sent. +// +// Returning a bare nil for this, as the Write-back senders once did, made a +// push that never left the building indistinguishable from one the Provider +// accepted — and left whatever last_error a previous attempt had written +// sitting beside the claim of success. A Sender wraps this around a reason +// specific enough to read months later: which race happened, not that one did. +var ErrSkipped = errors.New("send skipped") + // Store is the Worker's persistence seam. Satisfied by // *repository.OutboxRepository. type Store interface { ListPending(ctx context.Context, limit int) ([]repository.OutboxMessage, error) MarkSent(ctx context.Context, id int64, sentAt time.Time) error + MarkSkipped(ctx context.Context, id int64, reason string) error MarkRetry(ctx context.Context, id int64, attempts int, nextAttemptAt time.Time, lastErr string) error MarkFailed(ctx context.Context, id int64, attempts int, lastErr string) error } @@ -153,8 +180,19 @@ func (w *Worker) Tick(ctx context.Context) error { } if err := w.sender.Send(ctx, msg); err != nil { + // A skip is terminal but is not a failure: nothing was dispatched, + // nothing will be, and no marker is owed (ADR-0079). Checked before + // the retry arithmetic so it can never consume an attempt or a + // backoff slot. + if errors.Is(err, ErrSkipped) { + if merr := w.store.MarkSkipped(ctx, msg.ID, err.Error()); merr != nil { + log.Printf("outbox: mark skipped (id=%d): %v", msg.ID, merr) + } + continue + } + attempts := msg.Attempts + 1 - if attempts >= maxAttemptsFor(msg.Kind) { + if attempts >= maxAttemptsFor(msg.Kind) || errors.Is(err, ErrPermanent) { if merr := w.store.MarkFailed(ctx, msg.ID, attempts, err.Error()); merr != nil { log.Printf("outbox: mark failed (id=%d): %v", msg.ID, merr) } diff --git a/server/internal/outbox/worker_test.go b/server/internal/outbox/worker_test.go index f6a0cf7..a8d3202 100644 --- a/server/internal/outbox/worker_test.go +++ b/server/internal/outbox/worker_test.go @@ -3,6 +3,8 @@ package outbox import ( "context" "errors" + "fmt" + "strings" "testing" "time" @@ -55,6 +57,13 @@ func (f *fakeStore) MarkSent(_ context.Context, id int64, sentAt time.Time) erro return nil } +func (f *fakeStore) MarkSkipped(_ context.Context, id int64, reason string) error { + m := f.find(id) + m.Status = repository.OutboxStatusSkipped + m.LastError = reason + return nil +} + func (f *fakeStore) MarkRetry(_ context.Context, id int64, attempts int, nextAttemptAt time.Time, lastErr string) error { m := f.find(id) m.Attempts = attempts @@ -389,3 +398,91 @@ func TestWorker_Tick_NothingPendingIsANoOp(t *testing.T) { t.Fatalf("expected nothing sent, got calls %+v", sender.calls) } } + +// TestWorker_Tick_SkippedIsTerminalAndClaimsNoDelivery covers ADR-0079's +// central distinction. A Sender that dispatched nothing returns ErrSkipped, and +// the row must record that rather than the successful delivery a bare nil once +// implied: Status skipped, the reason kept, sent_at untouched, and no attempt +// consumed — there is nothing to retry, because the state that produced the +// skip does not resolve on its own. +func TestWorker_Tick_SkippedIsTerminalAndClaimsNoDelivery(t *testing.T) { + store := &fakeStore{messages: []repository.OutboxMessage{ + {ID: 1, EventID: "evt-1", Kind: repository.OutboxKindWriteBack, Method: repository.OutboxMethodPatch, Status: repository.OutboxStatusPending}, + }} + sender := &fakeSender{fail: map[int64]error{ + 1: fmt.Errorf("%w: this linked calendar is no longer writable at google", ErrSkipped), + }} + w := NewWorker(store, sender, func() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) }) + + if err := w.Tick(context.Background()); err != nil { + t.Fatalf("tick: %v", err) + } + + msg := store.messages[0] + if msg.Status != repository.OutboxStatusSkipped { + t.Fatalf("expected the message marked skipped, got %q", msg.Status) + } + if msg.SentAt != nil { + t.Fatalf("expected no sent_at on a message that was never dispatched, got %v", msg.SentAt) + } + if msg.Attempts != 0 { + t.Fatalf("expected a skip to consume no attempt, got %d", msg.Attempts) + } + if !strings.Contains(msg.LastError, "no longer writable") { + t.Fatalf("expected the skip reason recorded, got %q", msg.LastError) + } + + // Terminal: a second Tick must not pick it up again. + if err := w.Tick(context.Background()); err != nil { + t.Fatalf("second tick: %v", err) + } + if len(sender.calls) != 1 { + t.Fatalf("expected a skipped message never retried, got calls %+v", sender.calls) + } +} + +// TestWorker_Tick_PermanentFailureSkipsTheBackoffSchedule covers the other half +// of ADR-0079: a failure the Sender has classified as structural (a Google 4xx — +// a malformed body, a dead grant, a calendar the account may not write) fails on +// the first response instead of walking all four backoff entries. The delay +// mattered because a Write-back has no user-visible marker until its message is +// terminal, so retrying a hopeless push silently withholds the badge that says +// an edit never landed. +func TestWorker_Tick_PermanentFailureSkipsTheBackoffSchedule(t *testing.T) { + store := &fakeStore{messages: []repository.OutboxMessage{ + {ID: 1, EventID: "evt-1", Kind: repository.OutboxKindWriteBack, Method: repository.OutboxMethodPatch, Status: repository.OutboxStatusPending}, + }} + sender := &fakeSender{fail: map[int64]error{ + 1: fmt.Errorf("%w: could not push this event's changes to google: status 400", ErrPermanent), + }} + terminal := &recordingTerminalSender{fakeSender: sender} + w := NewWorker(store, terminal, func() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) }) + + if err := w.Tick(context.Background()); err != nil { + t.Fatalf("tick: %v", err) + } + + msg := store.messages[0] + if msg.Status != repository.OutboxStatusFailed { + t.Fatalf("expected the message failed on its first attempt, got %q after %d attempts", msg.Status, msg.Attempts) + } + if msg.Attempts != 1 { + t.Fatalf("expected exactly one attempt spent, got %d", msg.Attempts) + } + // The marker hook fires here, not thirteen minutes later. + if terminal.handled != 1 { + t.Fatalf("expected the terminal-failure handler called once, got %d", terminal.handled) + } +} + +// recordingTerminalSender is a fakeSender that also implements +// TerminalFailureHandler, so a test can assert the marker hook fired. +type recordingTerminalSender struct { + *fakeSender + handled int +} + +func (r *recordingTerminalSender) HandleTerminalFailure(_ context.Context, _ repository.OutboxMessage, _ error) error { + r.handled++ + return nil +} diff --git a/server/internal/repository/outbox.go b/server/internal/repository/outbox.go index 2e31689..590e833 100644 --- a/server/internal/repository/outbox.go +++ b/server/internal/repository/outbox.go @@ -15,6 +15,12 @@ import ( const ( OutboxStatusPending = "pending" OutboxStatusSent = "sent" + // OutboxStatusSkipped is terminal like Sent, but claims no delivery + // (ADR-0079): the message ended without ever being dispatched, and + // last_error carries why. Kept distinct from Sent so "did this reach the + // Provider?" is answerable from the row, and from Failed because a skip + // raises no per-Event marker — it is usually a race the User caused. + OutboxStatusSkipped = "skipped" OutboxStatusFailed = "failed" ) @@ -671,6 +677,22 @@ func (r *OutboxRepository) MarkSent(ctx context.Context, id int64, sentAt time.T return requireAffected(res) } +// MarkSkipped ends a message that was never dispatched (ADR-0079), recording +// the reason in the same last_error column a failure uses. Terminal: the Worker +// will not consider it again. sent_at is deliberately left NULL — nothing was +// sent, and a timestamp there would reintroduce exactly the ambiguity this +// status exists to remove. +func (r *OutboxRepository) MarkSkipped(ctx context.Context, id int64, reason string) error { + res, err := r.db.ExecContext(ctx, + `UPDATE outbox SET status = ?, last_error = ? WHERE id = ?`, + OutboxStatusSkipped, reason, id, + ) + if err != nil { + return fmt.Errorf("mark outbox message skipped: %w", err) + } + return requireAffected(res) +} + // MarkRetry records a failed attempt that hasn't exhausted its retries yet — // the message stays Pending, due again at nextAttemptAt (ADR-0060's backoff). func (r *OutboxRepository) MarkRetry(ctx context.Context, id int64, attempts int, nextAttemptAt time.Time, lastErr string) error { diff --git a/server/internal/service/connection_writeback.go b/server/internal/service/connection_writeback.go index aaee7b3..d79ede3 100644 --- a/server/internal/service/connection_writeback.go +++ b/server/internal/service/connection_writeback.go @@ -26,6 +26,7 @@ import ( "fmt" "net/http" + "github.com/XiovV/calich/server/internal/outbox" "github.com/XiovV/calich/server/internal/repository" ) @@ -102,6 +103,13 @@ func (s *ConnectionService) SendWriteBack(ctx context.Context, msg repository.Ou if !s.configured { return ErrGoogleNotConfigured } + return classifyWriteBackFailure(s.dispatchWriteBack(ctx, msg)) +} + +// dispatchWriteBack routes msg to the sender for its Method. SendWriteBack's +// own doc comment describes what each one does; this exists only so the +// classification above wraps every one of them rather than each remembering to. +func (s *ConnectionService) dispatchWriteBack(ctx context.Context, msg repository.OutboxMessage) error { switch msg.Method { case repository.OutboxMethodDelete: return s.sendWriteBackDelete(ctx, msg) @@ -114,6 +122,53 @@ func (s *ConnectionService) SendWriteBack(ctx context.Context, msg repository.Ou } } +// classifyWriteBackFailure marks a Google 4xx as outbox.ErrPermanent so the +// Worker fails the message on the first response instead of walking the whole +// backoff schedule. A 4xx says the request itself is unacceptable — a +// malformed body, a dead grant, a calendar the account may not write, an event +// that is gone — and none of those become acceptable by waiting. +// +// Three statuses stay on the retry path because they genuinely describe a +// moment rather than the request: 408 (the request timed out), 429 (rate +// limited, where waiting is exactly the remedy), and 412, which never reaches +// here in the ordinary case — SendWriteBack's own bounded loop refetches and +// re-issues it — but must not be reclassified underneath that loop if it does. +// A 5xx or a transport error is untouched: those are the outage backoff was +// built for. +// +// The cost of this is stated plainly, because it is the way the trade goes +// wrong: a 403 raised by an ACL change that is reverted a minute later now +// fails permanently where it might once have retried into a success. Editing +// the Event re-enqueues the push (ADR-0077), which is the recovery, and it is +// a better default than every genuinely hopeless push holding its Event's +// failure marker back for thirteen minutes. +func classifyWriteBackFailure(err error) error { + var httpErr *googleHTTPError + if !errors.As(err, &httpErr) { + return err + } + if httpErr.statusCode < 400 || httpErr.statusCode >= 500 { + return err + } + switch httpErr.statusCode { + case http.StatusRequestTimeout, http.StatusTooManyRequests, http.StatusPreconditionFailed: + return err + } + return fmt.Errorf("%w: %w", outbox.ErrPermanent, err) +} + +// skipWriteBack ends a push that was never dispatched, carrying the reason +// into the outbox row's last_error (ADR-0079). Every "nothing left to push" +// race SendWriteBack's doc comment names returns one of these instead of the +// bare nil it used to: nil is a claim that Google accepted the change, and +// none of these ever asked Google anything. +// +// The reasons are written to be read by a person looking at a row months +// later — which race happened, not merely that one did. +func skipWriteBack(reason string) error { + return fmt.Errorf("%w: %s", outbox.ErrSkipped, reason) +} + // writeBackContext is the Calendar and Connection a push runs against, once // resolveWritableLinkedContext has confirmed the Calendar is still a // writable Linked Calendar whose Connection still exists. @@ -123,29 +178,35 @@ type writeBackContext struct { calendarID string // the Provider's own calendar id } -// resolveWritableLinkedContext loads calendarID's Calendar and its -// Connection, returning ok=false (and a nil error) for every "nothing left -// to push" race SendWriteBack's own doc comment names: the Calendar gone, -// no longer a writable Connection Source, or its Connection disconnected. -func (s *ConnectionService) resolveWritableLinkedContext(ctx context.Context, calendarID string) (writeBackContext, bool, error) { +// resolveWritableLinkedContext loads calendarID's Calendar and its Connection, +// returning a skipWriteBack error for every "nothing left to push" race +// SendWriteBack's own doc comment names: the Calendar gone, no longer a +// writable Connection Source, or its Connection disconnected. Each of those is +// terminal — none resolves by retrying — and none of them dispatched anything, +// which is what separates the resulting 'skipped' row from a 'sent' one +// (ADR-0079). +func (s *ConnectionService) resolveWritableLinkedContext(ctx context.Context, calendarID string) (writeBackContext, error) { calendar, err := s.calendars.GetByIDUnchecked(ctx, calendarID) if err != nil { if errors.Is(err, repository.ErrNotFound) { - return writeBackContext{}, false, nil + return writeBackContext{}, skipWriteBack("this calendar no longer exists here") } - return writeBackContext{}, false, fmt.Errorf("load calendar for write-back: %w", err) + return writeBackContext{}, fmt.Errorf("load calendar for write-back: %w", err) + } + if calendar.Source == nil || calendar.Source.Kind != repository.SourceKindConnection { + return writeBackContext{}, skipWriteBack("this is no longer a linked calendar") } - if calendar.Source == nil || calendar.Source.Kind != repository.SourceKindConnection || calendar.Source.Mode != repository.SourceModeWritable { - return writeBackContext{}, false, nil + if calendar.Source.Mode != repository.SourceModeWritable { + return writeBackContext{}, skipWriteBack("this linked calendar is no longer writable at google") } conn, err := s.connections.GetByID(ctx, calendar.UserID, *calendar.Source.ConnectionID) if err != nil { if errors.Is(err, repository.ErrNotFound) { - return writeBackContext{}, false, nil + return writeBackContext{}, skipWriteBack("this linked calendar's connection was removed") } - return writeBackContext{}, false, fmt.Errorf("get connection: %w", err) + return writeBackContext{}, fmt.Errorf("get connection: %w", err) } - return writeBackContext{calendar: calendar, conn: conn, calendarID: *calendar.Source.ExternalCalendarID}, true, nil + return writeBackContext{calendar: calendar, conn: conn, calendarID: *calendar.Source.ExternalCalendarID}, nil } // accessToken returns the Connection's cached access token, "" when it has @@ -161,37 +222,46 @@ func (wc writeBackContext) accessToken() string { // for the two push kinds whose silent drop would be *undone* by the next // Refresh — DELETE (#292, ADR-0077) and CANCEL_INSTANCE (#293, ADR-0078) — // which still lists the event or occurrence Google was never told to remove. -// So where resolveWritableLinkedContext returns ok=false for every race, +// So where resolveWritableLinkedContext merely skips every race, // this one raises the Source's needs-attention marker (markWriteBackPermanentlyFailed) // for the two races that leave the User's intent unfulfilled — the Calendar // no longer writable, its Connection removed — and stays silent only for the // one true nothing-to-do, the Calendar itself gone. markEventID takes the // per-Event marker; verbing names the push for the marker's message // ("deleting this event", "cancelling this occurrence"). -func (s *ConnectionService) resolveDeliverableLinkedContext(ctx context.Context, calendarID, markEventID, verbing string) (writeBackContext, bool, error) { +func (s *ConnectionService) resolveDeliverableLinkedContext(ctx context.Context, calendarID, markEventID, verbing string) (writeBackContext, error) { calendar, err := s.calendars.GetByIDUnchecked(ctx, calendarID) if err != nil { if errors.Is(err, repository.ErrNotFound) { - return writeBackContext{}, false, nil + return writeBackContext{}, skipWriteBack("this calendar no longer exists here") } - return writeBackContext{}, false, fmt.Errorf("load calendar for write-back: %w", err) + return writeBackContext{}, fmt.Errorf("load calendar for write-back: %w", err) } if calendar.Source == nil || calendar.Source.Kind != repository.SourceKindConnection { - return writeBackContext{}, false, nil + return writeBackContext{}, skipWriteBack("this is no longer a linked calendar") } + // The two races that leave the User's intent unfulfilled raise the marker + // first and skip second: the row still claims no delivery, and the Source's + // needs-attention state is what actually reaches the User. if calendar.Source.Mode != repository.SourceModeWritable { - return writeBackContext{}, false, s.markWriteBackPermanentlyFailed(ctx, calendar.UserID, markEventID, calendar.ID, - "this linked calendar is no longer writable, so "+verbing+" could not be pushed to google") + reason := "this linked calendar is no longer writable, so " + verbing + " could not be pushed to google" + if err := s.markWriteBackPermanentlyFailed(ctx, calendar.UserID, markEventID, calendar.ID, reason); err != nil { + return writeBackContext{}, err + } + return writeBackContext{}, skipWriteBack(reason) } conn, err := s.connections.GetByID(ctx, calendar.UserID, *calendar.Source.ConnectionID) if err != nil { if errors.Is(err, repository.ErrNotFound) { - return writeBackContext{}, false, s.markWriteBackPermanentlyFailed(ctx, calendar.UserID, markEventID, calendar.ID, - "this linked calendar's connection was removed, so "+verbing+" could not be pushed to google") + reason := "this linked calendar's connection was removed, so " + verbing + " could not be pushed to google" + if merr := s.markWriteBackPermanentlyFailed(ctx, calendar.UserID, markEventID, calendar.ID, reason); merr != nil { + return writeBackContext{}, merr + } + return writeBackContext{}, skipWriteBack(reason) } - return writeBackContext{}, false, fmt.Errorf("get connection: %w", err) + return writeBackContext{}, fmt.Errorf("get connection: %w", err) } - return writeBackContext{calendar: calendar, conn: conn, calendarID: *calendar.Source.ExternalCalendarID}, true, nil + return writeBackContext{calendar: calendar, conn: conn, calendarID: *calendar.Source.ExternalCalendarID}, nil } // failOnDeadGrant maps a mintAccessToken failure to either a permanent @@ -215,7 +285,7 @@ func (s *ConnectionService) sendWriteBackUpsert(ctx context.Context, msg reposit event, err := s.events.GetByIDUnchecked(ctx, msg.EventID) if err != nil { if errors.Is(err, repository.ErrNotFound) { - return nil + return skipWriteBack("this event was deleted before its change could be pushed") } return fmt.Errorf("load event for write-back: %w", err) } @@ -224,11 +294,11 @@ func (s *ConnectionService) sendWriteBackUpsert(ctx context.Context, msg reposit if isCreate && msg.Method != repository.OutboxMethodPost { // A PATCH for an Event that never reached the Provider — #290's own // "never reached the Provider" no-op. - return nil + return skipWriteBack("this event was never created at google, so there was nothing to patch") } - wc, ok, err := s.resolveWritableLinkedContext(ctx, event.CalendarID) - if err != nil || !ok { + wc, err := s.resolveWritableLinkedContext(ctx, event.CalendarID) + if err != nil { return err } @@ -333,11 +403,11 @@ func (s *ConnectionService) sendWriteBackDelete(ctx context.Context, msg reposit snap := msg.WriteBackDelete if snap == nil || snap.ExternalUID == "" { // The Event never reached the Provider — nothing to delete there. - return nil + return skipWriteBack("this event was never created at google, so there was nothing to delete") } - wc, ok, err := s.resolveDeliverableLinkedContext(ctx, snap.CalendarID, msg.EventID, "deleting this event") - if err != nil || !ok { + wc, err := s.resolveDeliverableLinkedContext(ctx, snap.CalendarID, msg.EventID, "deleting this event") + if err != nil { return err } @@ -370,18 +440,18 @@ func (s *ConnectionService) sendWriteBackDelete(ctx context.Context, msg reposit func (s *ConnectionService) sendWriteBackInstance(ctx context.Context, msg repository.OutboxMessage) error { snap := msg.WriteBackInstance if snap == nil { - return nil + return skipWriteBack("this occurrence push carried no snapshot to address") } - wc, ok, err := s.resolveWritableLinkedContext(ctx, snap.CalendarID) - if err != nil || !ok { + wc, err := s.resolveWritableLinkedContext(ctx, snap.CalendarID) + if err != nil { return err } master, err := s.events.GetByIDUnchecked(ctx, snap.MasterEventID) if err != nil { if errors.Is(err, repository.ErrNotFound) { - return nil + return skipWriteBack("this occurrence's series was deleted before the change could be pushed") } return fmt.Errorf("load master for instance write-back: %w", err) } @@ -392,7 +462,7 @@ func (s *ConnectionService) sendWriteBackInstance(ctx context.Context, msg repos override, err := s.events.GetOverrideForWriteBack(ctx, snap.MasterEventID, snap.RecurrenceID) if err != nil { if errors.Is(err, repository.ErrNotFound) { - return nil + return skipWriteBack("this occurrence's change was undone before it could be pushed") } return fmt.Errorf("load override for instance write-back: %w", err) } @@ -409,7 +479,7 @@ func (s *ConnectionService) sendWriteBackInstance(ctx context.Context, msg repos instance, err = s.google.listInstances(ctx, accessToken, wc.calendarID, *master.ExternalUID, originalStart) } if errors.Is(err, errGoogleInstanceNotFound) { - return nil + return skipWriteBack("google no longer has this occurrence of the series") } if err != nil { return err @@ -471,7 +541,7 @@ func (s *ConnectionService) sendWriteBackInstance(ctx context.Context, msg repos func (s *ConnectionService) sendWriteBackInstanceCancel(ctx context.Context, msg repository.OutboxMessage) error { snap := msg.WriteBackInstance if snap == nil { - return nil + return skipWriteBack("this cancellation carried no snapshot to address") } markEventID := snap.OverrideEventID @@ -479,15 +549,15 @@ func (s *ConnectionService) sendWriteBackInstanceCancel(ctx context.Context, msg markEventID = snap.MasterEventID } - wc, ok, err := s.resolveDeliverableLinkedContext(ctx, snap.CalendarID, markEventID, "cancelling this occurrence") - if err != nil || !ok { + wc, err := s.resolveDeliverableLinkedContext(ctx, snap.CalendarID, markEventID, "cancelling this occurrence") + if err != nil { return err } master, err := s.events.GetByIDUnchecked(ctx, snap.MasterEventID) if err != nil { if errors.Is(err, repository.ErrNotFound) { - return nil + return skipWriteBack("this occurrence's series was deleted before the cancellation could be pushed") } return fmt.Errorf("load master for instance cancel: %w", err) } @@ -507,14 +577,15 @@ func (s *ConnectionService) sendWriteBackInstanceCancel(ctx context.Context, msg instance, err = s.google.listInstances(ctx, accessToken, wc.calendarID, *master.ExternalUID, originalStart) } if errors.Is(err, errGoogleInstanceNotFound) { - return nil + return skipWriteBack("google no longer has this occurrence of the series") } if err != nil { return err } if instance.Status == googleStatusCancelled { - // Already cancelled at Google — the end state the User asked for. - return nil + // Already cancelled at Google — the end state the User asked for, but + // reached without this push dispatching anything. + return skipWriteBack("this occurrence was already cancelled at google") } instanceID := instance.ID diff --git a/server/internal/service/connection_writeback_test.go b/server/internal/service/connection_writeback_test.go index d26eaba..064433a 100644 --- a/server/internal/service/connection_writeback_test.go +++ b/server/internal/service/connection_writeback_test.go @@ -4,9 +4,11 @@ import ( "context" "errors" "net/http" + "strings" "testing" "time" + "github.com/XiovV/calich/server/internal/outbox" "github.com/XiovV/calich/server/internal/repository" ) @@ -136,8 +138,11 @@ func TestConnectionService_SendWriteBack_RefusesReadOnlySourceWithoutCallingGoog t.Fatalf("enqueue write-back: %v", err) } - if err := svc.SendWriteBack(ctx, msg); err != nil { - t.Fatalf("expected SendWriteBack to treat a no-longer-writable Source as done, got %v", err) + // Terminal, and explicitly not a delivery: the row records why it never + // went out rather than claiming Google accepted it (ADR-0079). + err = svc.SendWriteBack(ctx, msg) + if !errors.Is(err, outbox.ErrSkipped) { + t.Fatalf("expected a no-longer-writable Source to skip the push, got %v", err) } if len(google.patchRequests) != 0 { t.Fatalf("expected no PATCH sent to a read-only Source, got %d", len(google.patchRequests)) @@ -147,7 +152,8 @@ func TestConnectionService_SendWriteBack_RefusesReadOnlySourceWithoutCallingGoog // TestConnectionService_SendWriteBack_NoOpWhenEventDeletedSinceQueued and // TestConnectionService_SendWriteBack_NoOpWhenNeverReachedTheProvider cover // the two other "nothing left to push" outcomes SendWriteBack's own doc -// comment names — both a success (mark sent), never a retry. +// comment names — both terminal (mark skipped, with the reason), never a retry +// and never a claim of delivery (ADR-0079). func TestConnectionService_SendWriteBack_NoOpWhenEventDeletedSinceQueued(t *testing.T) { google := newFakeGoogleServer(t) svc, g, _, _, _ := newTestConnectionServiceForWriteBack(t, google) @@ -156,8 +162,8 @@ func TestConnectionService_SendWriteBack_NoOpWhenEventDeletedSinceQueued(t *test if err != nil { t.Fatalf("enqueue write-back: %v", err) } - if err := svc.SendWriteBack(context.Background(), msg); err != nil { - t.Fatalf("expected a deleted event to no-op rather than error, got %v", err) + if err := svc.SendWriteBack(context.Background(), msg); !errors.Is(err, outbox.ErrSkipped) { + t.Fatalf("expected a deleted event to skip rather than error or claim delivery, got %v", err) } if len(google.patchRequests) != 0 { t.Fatalf("expected no PATCH sent for a deleted event, got %d", len(google.patchRequests)) @@ -185,8 +191,8 @@ func TestConnectionService_SendWriteBack_NoOpWhenNeverReachedTheProvider(t *test if err != nil { t.Fatalf("enqueue write-back: %v", err) } - if err := connSvc.SendWriteBack(ctx, msg); err != nil { - t.Fatalf("expected a never-linked event to no-op rather than error, got %v", err) + if err := connSvc.SendWriteBack(ctx, msg); !errors.Is(err, outbox.ErrSkipped) { + t.Fatalf("expected a never-linked event to skip rather than error or claim delivery, got %v", err) } if len(google.patchRequests) != 0 { t.Fatalf("expected no PATCH sent for an event with no ExternalUID, got %d", len(google.patchRequests)) @@ -570,3 +576,87 @@ func TestConnectionService_FullRefresh_NeverRevertsAnEventWithAPendingWriteBack( t.Fatalf("expected the local edit to survive the Refresh, got title %q", got.Title) } } + +// TestClassifyWriteBackFailure covers ADR-0079's retry classification: a Google +// 4xx describes a request that will be rejected identically every time, so it +// fails on the first response instead of walking the backoff schedule, while the +// three statuses that describe a moment rather than the request stay retryable. +// The original 400 this rule exists for — a malformed patch body — would +// otherwise have burned four retries over thirteen minutes before its Event +// showed any marker at all. +func TestClassifyWriteBackFailure(t *testing.T) { + tests := []struct { + name string + err error + permanent bool + }{ + {name: "400 is a body Google will reject identically forever", err: &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: http.StatusBadRequest}, permanent: true}, + {name: "401 is a grant that no longer authenticates", err: &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: http.StatusUnauthorized}, permanent: true}, + {name: "403 is a calendar this account may not write", err: &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: http.StatusForbidden}, permanent: true}, + {name: "404 is an event that is gone", err: &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: http.StatusNotFound}, permanent: true}, + {name: "408 describes a moment, not the request", err: &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: http.StatusRequestTimeout}}, + {name: "429 is the one where waiting is the remedy", err: &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: http.StatusTooManyRequests}}, + {name: "412 belongs to SendWriteBack's own refetch loop", err: &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: http.StatusPreconditionFailed}}, + {name: "500 is the outage backoff was built for", err: &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: http.StatusInternalServerError}}, + {name: "503 likewise", err: &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: http.StatusServiceUnavailable}}, + {name: "a transport error carries no status to classify", err: errors.New("dial tcp: connection refused")}, + {name: "a skip is not a failure and must not be reclassified", err: skipWriteBack("this calendar no longer exists here")}, + {name: "nil stays nil", err: nil}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := classifyWriteBackFailure(tc.err) + if errors.Is(got, outbox.ErrPermanent) != tc.permanent { + t.Fatalf("permanent = %v, want %v (got %v)", !tc.permanent, tc.permanent, got) + } + // Whatever the classification, the original error must survive it — + // the outbox row's last_error is where Google's own reason lands. + if tc.err != nil && !errors.Is(got, tc.err) { + t.Fatalf("expected the original error preserved, got %v", got) + } + }) + } +} + +// TestGoogleHTTPError_CarriesGooglesOwnReason covers the observability half of +// ADR-0079. Every call in google.go used to drain a failed response into +// io.Discard under a comment asserting it "carries nothing we need on failure", +// which is exactly backwards for a 4xx: Google's body names the offending field, +// and without it an outbox row reads "status 400" and explains nothing. +func TestGoogleHTTPError_CarriesGooglesOwnReason(t *testing.T) { + withBody := &googleHTTPError{ + sentinel: ErrGoogleWriteBackFailed, + statusCode: http.StatusBadRequest, + body: `{"error":{"message":"Invalid value for: Invalid format: \"\""}}`, + } + if !strings.Contains(withBody.Error(), "Invalid value for") { + t.Fatalf("expected Google's own reason in the error text, got %q", withBody.Error()) + } + if !strings.Contains(withBody.Error(), "status 400") { + t.Fatalf("expected the status kept alongside the reason, got %q", withBody.Error()) + } + + // A response with no body reads exactly as it did before, rather than + // trailing an empty separator. + bare := &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: http.StatusForbidden} + if got, want := bare.Error(), ErrGoogleWriteBackFailed.Error()+": status 403"; got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +// TestReadGoogleErrorBody_IsBoundedAndSingleLine covers the two properties that +// make storing a Provider's error body safe: a proxy or captive portal answering +// with a large HTML page cannot flood last_error, and Google's pretty-printed +// JSON collapses to one line for a column read in a terminal and a grid tooltip. +func TestReadGoogleErrorBody_IsBoundedAndSingleLine(t *testing.T) { + got := readGoogleErrorBody(strings.NewReader("{\n \"error\": {\n \"message\": \"bad\"\n }\n}")) + if want := `{ "error": { "message": "bad" } }`; got != want { + t.Fatalf("expected whitespace collapsed to one line\n got: %q\nwant: %q", got, want) + } + + flood := readGoogleErrorBody(strings.NewReader(strings.Repeat("x", googleErrorBodyLimit*4))) + if len(flood) != googleErrorBodyLimit { + t.Fatalf("expected the body bounded to %d bytes, got %d", googleErrorBodyLimit, len(flood)) + } +} diff --git a/server/internal/service/event_create_delete_writeback_test.go b/server/internal/service/event_create_delete_writeback_test.go index 413dcdd..6000176 100644 --- a/server/internal/service/event_create_delete_writeback_test.go +++ b/server/internal/service/event_create_delete_writeback_test.go @@ -2,10 +2,12 @@ package service import ( "context" + "errors" "net/http" "testing" "time" + "github.com/XiovV/calich/server/internal/outbox" "github.com/XiovV/calich/server/internal/repository" ) @@ -459,8 +461,10 @@ func TestConnectionService_SendWriteBack_DeleteRaisesNeedsAttentionWhenNoLongerW t.Fatalf("flip source read-only: %v", err) } - if err := svc.SendWriteBack(ctx, pending[0]); err != nil { - t.Fatalf("expected the undeliverable delete handled, got %v", err) + // Skipped, not sent: the delete never reached Google, and the Source's + // needs-attention state below is what actually reaches the User (ADR-0079). + if err := svc.SendWriteBack(ctx, pending[0]); !errors.Is(err, outbox.ErrSkipped) { + t.Fatalf("expected the undeliverable delete skipped, got %v", err) } if len(google.deleteRequests) != 0 { t.Fatalf("expected no delete sent to a read-only Source, got %d", len(google.deleteRequests)) diff --git a/server/internal/service/google.go b/server/internal/service/google.go index eb438e2..c3c55c6 100644 --- a/server/internal/service/google.go +++ b/server/internal/service/google.go @@ -106,14 +106,43 @@ var errGoogleSyncTokenExpired = errors.New("google sync token expired") type googleHTTPError struct { sentinel error statusCode int + // body is a bounded prefix of the failed response, empty when Google sent + // none. Google's 4xx bodies name the offending field — the difference + // between an outbox row reading "status 400" and one reading which field + // was wrong — so discarding them, as every call here used to, made a + // permanently failing push indistinguishable from a mystery. + body string } func (e *googleHTTPError) Error() string { - return fmt.Sprintf("%s: status %d", e.sentinel, e.statusCode) + if e.body == "" { + return fmt.Sprintf("%s: status %d", e.sentinel, e.statusCode) + } + return fmt.Sprintf("%s: status %d: %s", e.sentinel, e.statusCode, e.body) } func (e *googleHTTPError) Unwrap() error { return e.sentinel } +// googleErrorBodyLimit bounds how much of a failed response is kept. Google's +// own error JSON is well under this; the bound is here because a proxy or a +// captive portal in front of Google can answer with an HTML page instead, and +// that must not flood an outbox row's last_error or a log line. +const googleErrorBodyLimit = 512 + +// readGoogleErrorBody returns a bounded, single-line prefix of a failed +// response's body and drains the rest for keep-alive reuse — the drain every +// call site here already did, now keeping what it reads instead of discarding +// it. Whitespace is collapsed so the result stays one line wherever it is +// stored: last_error is read in a terminal and a grid tooltip, neither of which +// wants Google's pretty-printed JSON. Never returns an error: this runs on a +// path that is already failing, and a body that cannot be read is reported as +// the status code alone rather than replacing Google's reason with an I/O one. +func readGoogleErrorBody(body io.Reader) string { + buf, _ := io.ReadAll(io.LimitReader(body, googleErrorBodyLimit)) //nolint:errcheck // a partial read is still worth reporting; see doc comment + io.Copy(io.Discard, body) //nolint:errcheck // draining for keep-alive reuse + return strings.Join(strings.Fields(string(buf)), " ") +} + // googleClient is every HTTP call this app makes to Google, behind one // overridable client and one overridable set of endpoint URLs. type googleClient struct { @@ -187,8 +216,7 @@ func (c *googleClient) exchangeCode(ctx context.Context, code, redirectURI strin defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - io.Copy(io.Discard, resp.Body) //nolint:errcheck // draining for keep-alive reuse; the response carries nothing we need on failure - return googleTokens{}, fmt.Errorf("%w: token endpoint responded %d", ErrGoogleAuthFailed, resp.StatusCode) + return googleTokens{}, fmt.Errorf("%w: token endpoint responded %d: %s", ErrGoogleAuthFailed, resp.StatusCode, readGoogleErrorBody(resp.Body)) } var body struct { @@ -247,8 +275,7 @@ func (c *googleClient) fetchAccountEmail(ctx context.Context, accessToken string defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - io.Copy(io.Discard, resp.Body) //nolint:errcheck // draining for keep-alive reuse; the response carries nothing we need on failure - return "", false, fmt.Errorf("%w: userinfo endpoint responded %d", ErrGoogleAuthFailed, resp.StatusCode) + return "", false, fmt.Errorf("%w: userinfo endpoint responded %d: %s", ErrGoogleAuthFailed, resp.StatusCode, readGoogleErrorBody(resp.Body)) } var body struct { @@ -341,9 +368,9 @@ func (c *googleClient) listCalendarList(ctx context.Context, accessToken string) } if resp.StatusCode != http.StatusOK { - io.Copy(io.Discard, resp.Body) //nolint:errcheck // draining for keep-alive reuse; the response carries nothing we need on failure + errBody := readGoogleErrorBody(resp.Body) resp.Body.Close() - return nil, fmt.Errorf("%w: calendar list endpoint responded %d", ErrGoogleCalendarListFailed, resp.StatusCode) + return nil, fmt.Errorf("%w: calendar list endpoint responded %d: %s", ErrGoogleCalendarListFailed, resp.StatusCode, errBody) } var body struct { @@ -403,8 +430,7 @@ func (c *googleClient) getCalendarListEntry(ctx context.Context, accessToken, ca defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - io.Copy(io.Discard, resp.Body) //nolint:errcheck // draining for keep-alive reuse; the response carries nothing we need on failure - return googleCalendarListEntry{}, &googleHTTPError{sentinel: ErrGoogleCalendarListFailed, statusCode: resp.StatusCode} + return googleCalendarListEntry{}, &googleHTTPError{sentinel: ErrGoogleCalendarListFailed, statusCode: resp.StatusCode, body: readGoogleErrorBody(resp.Body)} } var item struct { @@ -463,8 +489,7 @@ func (c *googleClient) refreshAccessToken(ctx context.Context, refreshToken stri defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - io.Copy(io.Discard, resp.Body) //nolint:errcheck // draining for keep-alive reuse; the response carries nothing we need on failure - return googleRefreshedTokens{}, &googleHTTPError{sentinel: ErrGoogleTokenRefreshFailed, statusCode: resp.StatusCode} + return googleRefreshedTokens{}, &googleHTTPError{sentinel: ErrGoogleTokenRefreshFailed, statusCode: resp.StatusCode, body: readGoogleErrorBody(resp.Body)} } var body struct { @@ -644,12 +669,12 @@ func (c *googleClient) listEventChanges(ctx context.Context, accessToken, calend } if resp.StatusCode != http.StatusOK { - io.Copy(io.Discard, resp.Body) //nolint:errcheck // draining for keep-alive reuse; the response carries nothing we need on failure + errBody := readGoogleErrorBody(resp.Body) resp.Body.Close() if resp.StatusCode == http.StatusGone && syncToken != "" { return googleEventChanges{}, errGoogleSyncTokenExpired } - return googleEventChanges{}, &googleHTTPError{sentinel: ErrGoogleEventsFailed, statusCode: resp.StatusCode} + return googleEventChanges{}, &googleHTTPError{sentinel: ErrGoogleEventsFailed, statusCode: resp.StatusCode, body: errBody} } var body struct { @@ -720,13 +745,13 @@ type googleEventPatchBody struct { // id change on patch anyway. Not a content field — it addresses the // event, it does not describe it, so it does not widen what a caller can // smuggle through the field-scoped body. - ID string `json:"id,omitempty"` - Summary string `json:"summary"` - Description string `json:"description"` - Location string `json:"location"` - Start googleEventDateTimeJSON `json:"start"` - End googleEventDateTimeJSON `json:"end"` - Recurrence []string `json:"recurrence,omitempty"` + ID string `json:"id,omitempty"` + Summary string `json:"summary"` + Description string `json:"description"` + Location string `json:"location"` + Start googleEventDateTimePatchJSON `json:"start"` + End googleEventDateTimePatchJSON `json:"end"` + Recurrence []string `json:"recurrence,omitempty"` // Source is deliberately not ",omitempty": a nil pointer must marshal to // an explicit "source":null, the PATCH request that clears Event URL at // Google, rather than an absent key, which Google reads as "leave @@ -735,6 +760,64 @@ type googleEventPatchBody struct { Source *googleEventPatchSourceJSON `json:"source"` } +// googleEventDateTimePatchJSON is googleEventDateTimeJSON's write-side +// counterpart (ADR-0075): the same three fields, marshalled under Google's own +// rule that `date` and `dateTime` are mutually exclusive. The two directions +// genuinely disagree about what a zero value means — a decode wants every field +// present and bare, an encode must send exactly one of the pair — so they are +// separate types rather than one struct wearing tags that can only ever satisfy +// one of them. +// +// Sharing the decode struct is what broke Write-back outright: with no +// `omitempty`, every timed Event's patch shipped `"date":""` alongside its +// populated `dateTime`, and Google rejected the whole request with 400. Every +// push builds its boundaries here — PATCH, insert, instance patch and +// cancellation alike — so the failure was total rather than partial, and +// invisible to a suite that asserted which top-level keys the body carried but +// never descended into them. +// +// `omitempty` alone would have fixed that 400 and left a second one behind: an +// absent key reads to Google as "leave whatever is there alone", so editing a +// timed Event into an all-day one would strand the old `dateTime` beside the +// new `date` — the same forbidden pair, reached from the other direction. The +// unused half is therefore sent as an explicit null, which is what actually +// clears it, for the same reason googleEventPatchBody.Source above is +// deliberately not omitempty. +type googleEventDateTimePatchJSON struct { + Date string + DateTime string + TimeZone string +} + +// MarshalJSON enforces the mutually-exclusive pair at the seam — the same +// standard ADR-0075 sets for the field-scoped patch itself, enforced where the +// bytes are produced rather than by convention at each call site. Whichever of +// Date/DateTime is set is emitted and the other is emitted as an explicit null, +// so a body carrying both is not something a caller can express. A value +// carrying neither is a bug here, not a request for Google to judge, and fails +// before it can travel as an empty object. +func (d googleEventDateTimePatchJSON) MarshalJSON() ([]byte, error) { + if (d.Date == "") == (d.DateTime == "") { + return nil, fmt.Errorf("google event boundary must carry exactly one of date or dateTime (date=%q, dateTime=%q)", d.Date, d.DateTime) + } + + // TimeZone keeps an omitempty of its own: Google constrains date against + // dateTime, not against a zone, and a Floating Event (ADR-0019) carries no + // zone to send at all. + wire := struct { + Date *string `json:"date"` + DateTime *string `json:"dateTime"` + TimeZone string `json:"timeZone,omitempty"` + }{TimeZone: d.TimeZone} + if d.Date != "" { + wire.Date = &d.Date + } + if d.DateTime != "" { + wire.DateTime = &d.DateTime + } + return json.Marshal(wire) +} + // encodeGoogleEventDateTime is decodeGoogleTime's inverse (#290, ADR-0075): // an all-day boundary becomes a bare Date, a timed one becomes DateTime plus // TimeZone. tzid nil (a Floating Event) has no Google equivalent to round-trip @@ -743,12 +826,12 @@ type googleEventPatchBody struct { // push outright; this app's own Floating Event predates the timezone model // (ADR-0019) and a Linked Calendar's own Events always carry a concrete Anchor // zone from the Provider in practice. -func encodeGoogleEventDateTime(t time.Time, allDay bool, tzid *string) googleEventDateTimeJSON { +func encodeGoogleEventDateTime(t time.Time, allDay bool, tzid *string) googleEventDateTimePatchJSON { if allDay { - return googleEventDateTimeJSON{Date: t.UTC().Format("2006-01-02")} + return googleEventDateTimePatchJSON{Date: t.UTC().Format("2006-01-02")} } if tzid == nil { - return googleEventDateTimeJSON{DateTime: t.UTC().Format("2006-01-02T15:04:05Z")} + return googleEventDateTimePatchJSON{DateTime: t.UTC().Format("2006-01-02T15:04:05Z")} } loc, err := time.LoadLocation(*tzid) if err != nil { @@ -756,9 +839,9 @@ func encodeGoogleEventDateTime(t time.Time, allDay bool, tzid *string) googleEve // back to the UTC instant is lossy on the wall-clock but never wrong // about the moment in time, and TimeZone is deliberately left empty // rather than sent as a string Google itself cannot resolve either. - return googleEventDateTimeJSON{DateTime: t.UTC().Format("2006-01-02T15:04:05Z")} + return googleEventDateTimePatchJSON{DateTime: t.UTC().Format("2006-01-02T15:04:05Z")} } - return googleEventDateTimeJSON{DateTime: t.In(loc).Format("2006-01-02T15:04:05-07:00"), TimeZone: *tzid} + return googleEventDateTimePatchJSON{DateTime: t.In(loc).Format("2006-01-02T15:04:05-07:00"), TimeZone: *tzid} } // encodeGoogleRecurrence is parseGoogleRecurrence's inverse for the one line @@ -863,8 +946,7 @@ func (c *googleClient) patchEvent(ctx context.Context, accessToken, calendarID, defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - io.Copy(io.Discard, resp.Body) //nolint:errcheck // draining for keep-alive reuse; the response carries nothing we need on failure - return googleEventJSON{}, &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: resp.StatusCode} + return googleEventJSON{}, &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: resp.StatusCode, body: readGoogleErrorBody(resp.Body)} } var updated googleEventJSON @@ -894,8 +976,7 @@ func (c *googleClient) getEvent(ctx context.Context, accessToken, calendarID, ev defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - io.Copy(io.Discard, resp.Body) //nolint:errcheck // draining for keep-alive reuse; the response carries nothing we need on failure - return googleEventJSON{}, &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: resp.StatusCode} + return googleEventJSON{}, &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: resp.StatusCode, body: readGoogleErrorBody(resp.Body)} } var event googleEventJSON @@ -944,8 +1025,7 @@ func (c *googleClient) insertEvent(ctx context.Context, accessToken, calendarID return c.getEvent(ctx, accessToken, calendarID, body.ID) } if resp.StatusCode != http.StatusOK { - io.Copy(io.Discard, resp.Body) //nolint:errcheck // draining for keep-alive reuse; the response carries nothing we need on failure - return googleEventJSON{}, &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: resp.StatusCode} + return googleEventJSON{}, &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: resp.StatusCode, body: readGoogleErrorBody(resp.Body)} } var inserted googleEventJSON @@ -976,13 +1056,15 @@ func (c *googleClient) deleteEvent(ctx context.Context, accessToken, calendarID, return fmt.Errorf("%w: %v", ErrGoogleWriteBackFailed, err) } defer resp.Body.Close() - io.Copy(io.Discard, resp.Body) //nolint:errcheck // draining for keep-alive reuse; events.delete carries no body + // Drained whatever the status: a successful events.delete carries no body, + // and a failed one carries the reason. + errBody := readGoogleErrorBody(resp.Body) switch resp.StatusCode { case http.StatusOK, http.StatusNoContent, http.StatusNotFound, http.StatusGone: return nil default: - return &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: resp.StatusCode} + return &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: resp.StatusCode, body: errBody} } } @@ -1039,8 +1121,7 @@ func (c *googleClient) listInstances(ctx context.Context, accessToken, calendarI defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - io.Copy(io.Discard, resp.Body) //nolint:errcheck // draining for keep-alive reuse; the response carries nothing we need on failure - return googleEventJSON{}, &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: resp.StatusCode} + return googleEventJSON{}, &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: resp.StatusCode, body: readGoogleErrorBody(resp.Body)} } var body struct { @@ -1103,8 +1184,7 @@ func (c *googleClient) cancelInstance(ctx context.Context, accessToken, calendar return googleEventJSON{}, nil } if resp.StatusCode != http.StatusOK { - io.Copy(io.Discard, resp.Body) //nolint:errcheck // draining for keep-alive reuse; the response carries nothing we need on failure - return googleEventJSON{}, &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: resp.StatusCode} + return googleEventJSON{}, &googleHTTPError{sentinel: ErrGoogleWriteBackFailed, statusCode: resp.StatusCode, body: readGoogleErrorBody(resp.Body)} } var updated googleEventJSON diff --git a/server/internal/service/google_writeback_test.go b/server/internal/service/google_writeback_test.go index ac696b7..eaf300a 100644 --- a/server/internal/service/google_writeback_test.go +++ b/server/internal/service/google_writeback_test.go @@ -226,3 +226,106 @@ func TestBuildGooglePatch_NonHTTPEventURLIsNeverSent(t *testing.T) { t.Fatalf("expected no source for a non-http Event URL, got %+v", patch.Source) } } + +// TestBuildGooglePatch_GoldenWireBodies pins the exact bytes every Write-back +// push sends, one case per encoding shape. It exists because its absence is +// what let Write-back ship broken: the assertions above this one check which +// top-level keys the body carries and never descend into `start`/`end`, so a +// shared decode struct with no `omitempty` shipped `"date":""` beside every +// populated `dateTime` and Google rejected every push with 400 — an edit, a +// create, an instance patch and a cancellation alike, since all four build +// their boundaries through encodeGoogleEventDateTime. +// +// The rule those bytes encode is stated in googleEventDateTimePatchJSON's own +// doc comment: `date` and `dateTime` are mutually exclusive at Google, exactly +// one is sent, and the unused half is an explicit null rather than an absent +// key so that editing a timed Event into an all-day one actually clears the +// stale value. A golden is the right shape for this because the failure mode is +// a change to the *wire format* that no type-level assertion can see; it should +// be updated deliberately, in a diff a reviewer can read, and never loosened +// into a partial match. +func TestBuildGooglePatch_GoldenWireBodies(t *testing.T) { + sarajevo := "Europe/Sarajevo" + start := time.Date(2026, 9, 10, 14, 0, 0, 0, time.UTC) + end := time.Date(2026, 9, 10, 15, 0, 0, 0, time.UTC) + + tests := []struct { + name string + patch googleEventPatchBody + want string + }{ + { + name: "timed with an Anchor zone sends dateTime and timeZone, date explicitly null", + patch: buildGooglePatch("Standup", start, end, false, &sarajevo, "", "", "", ""), + want: `{"summary":"Standup","description":"","location":"","start":{"date":null,"dateTime":"2026-09-10T16:00:00+02:00","timeZone":"Europe/Sarajevo"},"end":{"date":null,"dateTime":"2026-09-10T17:00:00+02:00","timeZone":"Europe/Sarajevo"},"source":null}`, + }, + { + name: "all-day sends a bare date, dateTime explicitly null and no zone at all", + patch: buildGooglePatch("Holiday", start, end, true, &sarajevo, "", "", "", ""), + want: `{"summary":"Holiday","description":"","location":"","start":{"date":"2026-09-10","dateTime":null},"end":{"date":"2026-09-10","dateTime":null},"source":null}`, + }, + { + // A Floating Event (ADR-0019) has no zone to round-trip through, so + // it travels as a "Z" instant with timeZone omitted entirely. + name: "floating sends a Z instant with no timeZone key", + patch: buildGooglePatch("Floats", start, end, false, nil, "", "", "", ""), + want: `{"summary":"Floats","description":"","location":"","start":{"date":null,"dateTime":"2026-09-10T14:00:00Z"},"end":{"date":null,"dateTime":"2026-09-10T15:00:00Z"},"source":null}`, + }, + { + // The recurrence array is exactly the one RRULE line — never an + // EXDATE, which ADR-0078 forbids as a second representation of a + // cancellation Google already models as a cancelled instance. + name: "recurring adds exactly one RRULE line", + patch: buildGooglePatch("Weekly", start, end, false, &sarajevo, "FREQ=WEEKLY", "", "", ""), + want: `{"summary":"Weekly","description":"","location":"","start":{"date":null,"dateTime":"2026-09-10T16:00:00+02:00","timeZone":"Europe/Sarajevo"},"end":{"date":null,"dateTime":"2026-09-10T17:00:00+02:00","timeZone":"Europe/Sarajevo"},"recurrence":["RRULE:FREQ=WEEKLY"],"source":null}`, + }, + { + name: "an Event URL becomes a source object", + patch: buildGooglePatch("Linked", start, end, false, &sarajevo, "", "", "", "https://example.com/x"), + want: `{"summary":"Linked","description":"","location":"","start":{"date":null,"dateTime":"2026-09-10T16:00:00+02:00","timeZone":"Europe/Sarajevo"},"end":{"date":null,"dateTime":"2026-09-10T17:00:00+02:00","timeZone":"Europe/Sarajevo"},"source":{"title":"Linked","url":"https://example.com/x"}}`, + }, + { + // Clearing Event URL is why Source is not omitempty: an absent key + // would read to Google as "leave whatever is there alone". + name: "no Event URL sends source as an explicit null, not an absent key", + patch: buildGooglePatch("Bare", start, end, false, nil, "", "some notes", "a room", ""), + want: `{"summary":"Bare","description":"some notes","location":"a room","start":{"date":null,"dateTime":"2026-09-10T14:00:00Z"},"end":{"date":null,"dateTime":"2026-09-10T15:00:00Z"},"source":null}`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := json.Marshal(tc.patch) + if err != nil { + t.Fatalf("marshal patch: %v", err) + } + if string(got) != tc.want { + t.Fatalf("wire body mismatch\n got: %s\nwant: %s", got, tc.want) + } + }) + } +} + +// TestGoogleEventDateTimePatchJSON_RefusesAnAmbiguousBoundary covers the +// invariant the golden bodies above are instances of: a boundary carrying both +// date and dateTime, or neither, is the malformed pair Google answers with 400, +// so it fails at the seam rather than travelling. Enforcing it in MarshalJSON +// is what makes "exactly one of the pair" true by construction — there is no +// call site that can opt out of it. +func TestGoogleEventDateTimePatchJSON_RefusesAnAmbiguousBoundary(t *testing.T) { + tests := []struct { + name string + dt googleEventDateTimePatchJSON + }{ + {name: "both set is the pair Google rejects", dt: googleEventDateTimePatchJSON{Date: "2026-09-10", DateTime: "2026-09-10T14:00:00Z"}}, + {name: "neither set carries no boundary at all", dt: googleEventDateTimePatchJSON{TimeZone: "Europe/Sarajevo"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if _, err := json.Marshal(tc.dt); err == nil { + t.Fatal("expected marshalling an ambiguous boundary to fail, got nil error") + } + }) + } +} diff --git a/src/components/layout/CalendarList.test.tsx b/src/components/layout/CalendarList.test.tsx index eadcdb8..5e99c57 100644 --- a/src/components/layout/CalendarList.test.tsx +++ b/src/components/layout/CalendarList.test.tsx @@ -10,7 +10,7 @@ vi.mock("../../lib/connectionsApi", async () => { const actual = await vi.importActual("../../lib/connectionsApi"); return { ...actual, - connectionsApi: { listPickerCalendars: vi.fn(), importCalendars: vi.fn() }, + connectionsApi: { list: vi.fn(async () => []), listPickerCalendars: vi.fn(), importCalendars: vi.fn() }, }; }); vi.mock("../../lib/icsApi", () => ({ @@ -23,6 +23,7 @@ const { useCalendarsStore } = await import("../../lib/calendarsStore"); const { useEventsStore } = await import("../../lib/eventsStore"); const { useShellStore } = await import("../../lib/shellStore"); const { useWorkspacesStore } = await import("../../lib/workspacesStore"); +const { useConnectionsStore } = await import("../../lib/connectionsStore"); const { connectionsApi } = await import("../../lib/connectionsApi"); const { CalendarList } = await import("./CalendarList"); @@ -50,6 +51,7 @@ beforeEach(() => { useAuthStore.setState({ accessToken: "token-123" }); useEventsStore.setState({ events: [] }); useShellStore.setState({ checkedCalendarIds: new Set() }); + useConnectionsStore.setState({ connections: [] }); }); async function openMenu(calendarName: string) { @@ -89,6 +91,7 @@ describe("Linked Calendar grouping", () => { access: "viewer", isOwner: true, sourceKind: "connection", + connectionId: 1, connectionAccountEmail: "work@gmail.com", }; const personalLinked: Calendar = { @@ -98,6 +101,7 @@ describe("Linked Calendar grouping", () => { access: "viewer", isOwner: true, sourceKind: "connection", + connectionId: 2, connectionAccountEmail: "personal@gmail.com", }; @@ -148,6 +152,7 @@ describe("Linked Calendar grouping", () => { isOwner: false, ownerName: "Bob", sourceKind: "connection", + connectionId: 3, connectionAccountEmail: "bob@gmail.com", }; useCalendarsStore.setState({ calendars: [owned, sharedLinked] }); @@ -161,6 +166,48 @@ describe("Linked Calendar grouping", () => { expect(screen.getByText("Shared by Bob")).toBeInTheDocument(); }); + // The grouping keys on the Connection id, never on the account Email. The + // Email is populated only by the *list* endpoint's batched join; the + // single-Calendar GET that refreshCalendar re-reads a Linked Calendar with + // omits it, so a Calendar that has just been refreshed arrives back in the + // store with connectionAccountEmail undefined. Keying on the Email dropped + // it into an "Unknown account" heading until an unrelated list fetch put the + // Email back — visible as the Calendar jumping between headings for a few + // seconds after every Refresh. + it("keeps a refreshed Linked Calendar in its Connection's group when the account email is missing", () => { + useConnectionsStore.setState({ + connections: [ + { id: 1, provider: "google", accountEmail: "work@gmail.com", status: "live", createdAt: "2026-01-01T00:00:00Z" }, + ], + }); + // Exactly what calendarsApi.get returns: the Connection id, no Email. + const refreshed: Calendar = { ...workLinked, connectionAccountEmail: undefined }; + useCalendarsStore.setState({ calendars: [owned, refreshed] }); + render(); + + expect(screen.getByText("work@gmail.com")).toBeInTheDocument(); + expect(screen.queryByText("Unknown account")).not.toBeInTheDocument(); + expect(screen.getByText("Work")).toBeInTheDocument(); + }); + + // The two halves of one Connection must not split across two headings just + // because one of them was refreshed and the other was not — the case the + // screenshots showed, with a single account rendering twice. + it("keeps a refreshed and an unrefreshed Linked Calendar under one heading", () => { + useConnectionsStore.setState({ + connections: [ + { id: 1, provider: "google", accountEmail: "work@gmail.com", status: "live", createdAt: "2026-01-01T00:00:00Z" }, + ], + }); + const sibling: Calendar = { ...workLinked, id: "cal-work-2", name: "Work Two" }; + const refreshed: Calendar = { ...workLinked, connectionAccountEmail: undefined }; + useCalendarsStore.setState({ calendars: [refreshed, sibling] }); + render(); + + expect(screen.getAllByText("work@gmail.com")).toHaveLength(1); + expect(screen.queryByText("Unknown account")).not.toBeInTheDocument(); + }); + // #288: a Linked Calendar can be refreshed on demand, exactly like a // Subscribed one — and, like one, offers no per-Calendar Export. it("offers Refresh and not Export on a Linked Calendar", async () => { diff --git a/src/components/layout/CalendarList.tsx b/src/components/layout/CalendarList.tsx index eba441e..1297cc6 100644 --- a/src/components/layout/CalendarList.tsx +++ b/src/components/layout/CalendarList.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { formatDistanceToNow } from "date-fns"; import { Menu } from "@base-ui/react/menu"; import { MoreVertical, Plus, TriangleAlert, Users } from "lucide-react"; @@ -9,6 +9,7 @@ import { canManageCalendar, isLinkedCalendar, shareCountTooltip, type Calendar } import { resolveCalendarFill } from "../../lib/calendarColors"; import { useAuthStore } from "../../lib/authStore"; import { useCalendarsStore } from "../../lib/calendarsStore"; +import { useConnectionsStore } from "../../lib/connectionsStore"; import { useEventsStore } from "../../lib/eventsStore"; import { useShellStore } from "../../lib/shellStore"; import { deleteCalendarCascade } from "../../lib/deleteCalendarCascade"; @@ -27,6 +28,13 @@ import { ShareCalendarModal } from "./ShareCalendarModal"; // The row's action menu items share this shape so every row type — a click // away from Edit-only, Edit+Export+Delete, or Edit+Refresh+Unsubscribe — is // one list instead of three near-duplicate menus (#189). +// UNRESOLVED_CONNECTION is the grouping key for a Linked Calendar whose +// Connection id is missing from the payload — a state the server should never +// produce, kept so that such a Calendar still renders instead of disappearing. +// The heading reads "Unknown account" and offers no picker button, since there +// is no Connection to re-open it with. +const UNRESOLVED_CONNECTION = Symbol("unresolved connection"); + const menuItemClasses = "flex cursor-default items-center px-3 py-1.5 text-body text-ink data-[highlighted]:bg-surface-hover data-[disabled]:pointer-events-none data-[disabled]:opacity-50"; const destructiveMenuItemClasses = @@ -53,6 +61,21 @@ export function CalendarList() { (state) => state.toggleCalendarChecked, ); const refreshCalendar = useCalendarsStore((state) => state.refreshCalendar); + // Linked Calendars group by Connection, so the sidebar needs the + // Connections themselves to label each heading — it can no longer rely on + // the account Email riding along on every Calendar, which only the list + // endpoint populates (see linkedCalendarsByConnection below). Settings used + // to be the only place that fetched these; a User who never opens Settings + // would otherwise see every heading fall back to "Unknown account". + const connections = useConnectionsStore((state) => state.connections); + const fetchConnections = useConnectionsStore((state) => state.fetchConnections); + useEffect(() => { + if (!accessToken) return; + // A failure here costs the heading its label and nothing else, so it is + // swallowed rather than surfaced: the sidebar's Calendars still render, + // still group correctly, and still toggle. + fetchConnections().catch(() => {}); + }, [accessToken, fetchConnections]); const [isCreateOpen, setIsCreateOpen] = useState(false); const [isSubscribeOpen, setIsSubscribeOpen] = useState(false); // The Calendar picker, re-opened from a Connection's sidebar heading to @@ -95,17 +118,47 @@ export function CalendarList() { // Linked Calendars group under one heading per Connection rather than // beside every other owned Calendar (#286) — the sidebar's only way to // show "these came from that Google account". - const linkedCalendarsByConnection = new Map< - string, - { connectionId?: number; calendars: Calendar[] } - >(); + // + // Keyed on the Connection's id, never on the account Email. The Email is a + // denormalized display field that only the *list* endpoint populates + // (attachConnectionEmails); every single-Calendar response omits it. Keying + // on it meant that refreshing a Linked Calendar — which replaces its store + // row with the single-Calendar GET's response — dropped the Calendar into an + // "Unknown account" heading until an unrelated list fetch happened to put + // the Email back. The id is on every response that carries a Source at all, + // so the grouping no longer depends on which endpoint a Calendar last came + // from. + // A Linked Calendar with no connectionId should not be reachable — the + // server sets it on every response carrying a Connection Source — but it + // groups under UNRESOLVED_CONNECTION rather than being dropped. A Calendar + // vanishing from the sidebar entirely is a far worse failure than one + // sitting under an unnamed heading, and this is the sidebar's last line + // against a payload shape changing underneath it. + const linkedCalendarsByConnection = new Map(); for (const calendar of calendars) { if (!canManageCalendar(calendar) || !isLinkedCalendar(calendar)) continue; - const email = calendar.connectionAccountEmail ?? "Unknown account"; - const group = linkedCalendarsByConnection.get(email) ?? { calendars: [] }; - group.calendars.push(calendar); - group.connectionId ??= calendar.connectionId; - linkedCalendarsByConnection.set(email, group); + const key = calendar.connectionId ?? UNRESOLVED_CONNECTION; + const group = linkedCalendarsByConnection.get(key) ?? []; + group.push(calendar); + linkedCalendarsByConnection.set(key, group); + } + + // The heading's label, resolved at render rather than carried on the rows. + // The Connections store is the durable source; a Calendar's own + // connectionAccountEmail stands in while that first fetch is still in + // flight, and "Unknown account" is now a genuine unresolved-Connection + // state rather than an artefact of which endpoint answered last. + function connectionLabel( + connectionId: number | typeof UNRESOLVED_CONNECTION, + group: Calendar[], + ): string { + return ( + (connectionId !== UNRESOLVED_CONNECTION + ? connections.find((connection) => connection.id === connectionId)?.accountEmail + : undefined) ?? + group.find((calendar) => calendar.connectionAccountEmail)?.connectionAccountEmail ?? + "Unknown account" + ); } const sharedCalendars = calendars.filter( (calendar) => !canManageCalendar(calendar), @@ -388,24 +441,27 @@ export function CalendarList() { {/* One heading per Connection, labelled with the connected account's Email (#286) — two connected accounts produce two separate headings, since each is its own Map entry. */} - {Array.from(linkedCalendarsByConnection.entries()).map(([accountEmail, group]) => ( -
-
-

{accountEmail}

- {group.connectionId !== undefined && ( - setPickerConnectionId(group.connectionId ?? null)} - aria-label={`Choose calendars from ${accountEmail}`} - title={`Choose calendars from ${accountEmail}`} - > - - - )} + {Array.from(linkedCalendarsByConnection.entries()).map(([connectionId, group]) => { + const accountEmail = connectionLabel(connectionId, group); + return ( +
+
+

{accountEmail}

+ {connectionId !== UNRESOLVED_CONNECTION && ( + setPickerConnectionId(connectionId)} + aria-label={`Choose calendars from ${accountEmail}`} + title={`Choose calendars from ${accountEmail}`} + > + + + )} +
+
    {group.map(renderCalendarItem)}
-
    {group.calendars.map(renderCalendarItem)}
-
- ))} + ); + })} {sharedCalendars.length > 0 && ( <> diff --git a/src/lib/calendar.ts b/src/lib/calendar.ts index c19aab3..67c76e1 100644 --- a/src/lib/calendar.ts +++ b/src/lib/calendar.ts @@ -78,9 +78,12 @@ export function isSubscribedCalendar(calendar: Calendar | undefined): boolean { } // isLinkedCalendar reports whether calendar is a Linked Calendar (#286, -// ADR-0052): mirrored from one calendar of a Connection, and read-only here -// until write-back ships — see calendarReadOnlyReason. Grouped in the -// sidebar under its Connection's heading rather than under "My calendars". +// ADR-0052): mirrored from one calendar of a Connection. Writable or not +// depending on what the Provider's own ACL grants the connecting account +// (ADR-0075) — which is why writability is read from access, never from this +// predicate; see calendarReadOnlyReason. Grouped in the sidebar under its +// Connection's heading rather than under "My calendars", keyed on the +// Connection's id. export function isLinkedCalendar(calendar: Calendar | undefined): boolean { return calendar?.sourceKind === "connection"; } diff --git a/src/settings/CalendarPickerModal.test.tsx b/src/settings/CalendarPickerModal.test.tsx index f3d9874..eaf1d08 100644 --- a/src/settings/CalendarPickerModal.test.tsx +++ b/src/settings/CalendarPickerModal.test.tsx @@ -15,11 +15,15 @@ vi.mock("../lib/connectionsApi", async () => { }; }); vi.mock("../lib/calendarsApi", () => ({ calendarsApi: { list: vi.fn(), remove: vi.fn() } })); +vi.mock("../lib/eventsApi", () => ({ eventsApi: { list: vi.fn() } })); const { connectionsApi } = await import("../lib/connectionsApi"); const { calendarsApi } = await import("../lib/calendarsApi"); +const { eventsApi } = await import("../lib/eventsApi"); const { useAuthStore } = await import("../lib/authStore"); const { useCalendarsStore } = await import("../lib/calendarsStore"); +const { useEventsStore } = await import("../lib/eventsStore"); +const { useShellStore } = await import("../lib/shellStore"); const { useWorkspacesStore } = await import("../lib/workspacesStore"); const { CalendarPickerModal } = await import("./CalendarPickerModal"); @@ -48,8 +52,10 @@ beforeEach(() => { vi.clearAllMocks(); useAuthStore.setState({ accessToken: "token-123" }); useCalendarsStore.setState({ calendars: [] }); + useEventsStore.setState({ events: [] }); useWorkspacesStore.setState({ activeWorkspaceId: 7 }); vi.mocked(calendarsApi.list).mockResolvedValue([]); + vi.mocked(eventsApi.list).mockResolvedValue([]); }); afterEach(() => { @@ -82,6 +88,62 @@ describe("CalendarPickerModal", () => { expect(onClose).toHaveBeenCalled(); }); + // The import created a new Linked Calendar server-side; fetchCalendars is + // the only way this session learns its id. Without a reconcile after it — + // the same gap ImportExportSection's ICS import closed for #229 — the new + // Calendar's toggle stayed unset until a reload. + it("checks the imported calendar's toggle immediately, without a reload", async () => { + vi.mocked(connectionsApi.listPickerCalendars).mockResolvedValue([primary]); + vi.mocked(connectionsApi.importCalendars).mockResolvedValue([ + { id: "cal-1", name: "someone@gmail.com", color: "#0B8043FF" }, + ]); + vi.mocked(calendarsApi.list).mockResolvedValue([ + { id: "cal-1", name: "someone@gmail.com", color: "#0B8043FF", isOwner: true, access: "owner" }, + ]); + render(); + + await screen.findByLabelText("someone@gmail.com"); + await userEvent.click(screen.getByRole("button", { name: /Add 1 calendar/ })); + + await waitFor(() => + expect(useShellStore.getState().checkedCalendarIds.has("cal-1")).toBe(true), + ); + expect(useShellStore.getState().knownCalendarIds.has("cal-1")).toBe(true); + }); + + // The import created a new Linked Calendar's Events server-side too; + // nothing refetches eventsStore after Confirm, so its Events sat missing + // from the grid until the next mount/focus/workspace-change refetch — + // visible as "alt-tab and back makes them appear". + it("shows the imported calendar's events immediately, without a reload", async () => { + vi.mocked(connectionsApi.listPickerCalendars).mockResolvedValue([primary]); + vi.mocked(connectionsApi.importCalendars).mockResolvedValue([ + { id: "cal-1", name: "someone@gmail.com", color: "#0B8043FF" }, + ]); + vi.mocked(calendarsApi.list).mockResolvedValue([ + { id: "cal-1", name: "someone@gmail.com", color: "#0B8043FF", isOwner: true, access: "owner" }, + ]); + vi.mocked(eventsApi.list).mockResolvedValue([ + { + id: "evt-1", + calendarId: "cal-1", + title: "Google calendar event", + start: new Date("2026-09-08T11:15:00Z"), + end: new Date("2026-09-08T14:15:00Z"), + }, + ]); + render(); + + await screen.findByLabelText("someone@gmail.com"); + await userEvent.click(screen.getByRole("button", { name: /Add 1 calendar/ })); + + await waitFor(() => + expect(useEventsStore.getState().events.some((event) => event.id === "evt-1")).toBe( + true, + ), + ); + }); + it("toggling a row changes what gets imported", async () => { vi.mocked(connectionsApi.listPickerCalendars).mockResolvedValue([primary, sharedIn]); vi.mocked(connectionsApi.importCalendars).mockResolvedValue([]); diff --git a/src/settings/CalendarPickerModal.tsx b/src/settings/CalendarPickerModal.tsx index 0ada96a..414da98 100644 --- a/src/settings/CalendarPickerModal.tsx +++ b/src/settings/CalendarPickerModal.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from "react"; import { Dialog } from "@base-ui/react/dialog"; -import { useCalendarsStore } from "../lib/calendarsStore"; import { useConnectionsStore } from "../lib/connectionsStore"; +import { useEventsStore } from "../lib/eventsStore"; +import { refetchCalendarsAndReconcile } from "../lib/shellStore"; import { type PickerCalendar } from "../lib/connectionsApi"; import { useWorkspacesStore } from "../lib/workspacesStore"; import { deleteCalendarCascade } from "../lib/deleteCalendarCascade"; @@ -31,7 +32,7 @@ interface CalendarPickerModalProps { export function CalendarPickerModal({ connectionId, onClose }: CalendarPickerModalProps) { const listPickerCalendars = useConnectionsStore((state) => state.listPickerCalendars); const importCalendars = useConnectionsStore((state) => state.importCalendars); - const fetchCalendars = useCalendarsStore((state) => state.fetchCalendars); + const fetchEvents = useEventsStore((state) => state.fetchEvents); // The picker's own trigger — Connect's redirect landing back on a fresh // full-page load — is the one moment nothing here can assume the active // Workspace has resolved yet: AppShell's own fetchWorkspaces() fires on @@ -159,8 +160,14 @@ export function CalendarPickerModal({ connectionId, onClose }: CalendarPickerMod // immediately (#286) — a full re-fetch, not a local append, since the // server resolves each new Calendar's Access/isOwner/ownerName/ // shareCount and this is a one-time action rather than a hot path - // worth optimizing. - await fetchCalendars(); + // worth optimizing. Reconciling (not a plain fetchCalendars) is what + // checks the newly-imported Calendar's toggle without a reload — the + // same gap ImportExportSection's ICS import closed for #229. Its + // Events need their own refetch alongside it, same as + // ImportExportSection.handleConfirmImport — otherwise the grid stays + // empty for the new Calendar until the next mount/focus/workspace + // refetch. + await Promise.all([refetchCalendarsAndReconcile(), fetchEvents()]); onClose(); } catch (err) { setError(errorMessage(err)); diff --git a/src/settings/DisconnectConnectionModal.test.tsx b/src/settings/DisconnectConnectionModal.test.tsx new file mode 100644 index 0000000..19b4dc0 --- /dev/null +++ b/src/settings/DisconnectConnectionModal.test.tsx @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// Same convention as CalendarPickerModal.test.tsx: the *Api modules are +// mocked, the stores are real. +vi.mock("../lib/connectionsApi", async () => { + const actual = + await vi.importActual("../lib/connectionsApi"); + return { + ...actual, + connectionsApi: { + disconnectImpact: vi.fn(), + disconnect: vi.fn(), + }, + }; +}); +vi.mock("../lib/calendarsApi", () => ({ calendarsApi: { list: vi.fn() } })); + +const { connectionsApi } = await import("../lib/connectionsApi"); +const { calendarsApi } = await import("../lib/calendarsApi"); +const { useAuthStore } = await import("../lib/authStore"); +const { useCalendarsStore } = await import("../lib/calendarsStore"); +const { useEventsStore } = await import("../lib/eventsStore"); +const { useShellStore } = await import("../lib/shellStore"); +const { DisconnectConnectionModal } = await import("./DisconnectConnectionModal"); + +const linkedCalendar = { id: "cal-1", name: "someone@gmail.com", shareCount: 0 }; + +const orphanedEvent = { + id: "evt-1", + calendarId: "cal-1", + title: "Google calendar event", + start: new Date("2026-09-08T11:15:00Z"), + end: new Date("2026-09-08T14:15:00Z"), +}; + +beforeEach(() => { + vi.clearAllMocks(); + useAuthStore.setState({ accessToken: "token-123" }); + useCalendarsStore.setState({ calendars: [] }); + useEventsStore.setState({ events: [orphanedEvent] }); + useShellStore.setState({ checkedCalendarIds: new Set(["cal-1"]) }); + vi.mocked(calendarsApi.list).mockResolvedValue([]); + vi.mocked(connectionsApi.disconnectImpact).mockResolvedValue({ + linkedCalendars: [linkedCalendar], + }); + vi.mocked(connectionsApi.disconnect).mockResolvedValue(undefined); +}); + +afterEach(() => { + useShellStore.setState({ checkedCalendarIds: new Set() }); +}); + +describe("DisconnectConnectionModal", () => { + // Disconnecting with the "delete" disposition removes the Linked + // Calendars and their Events server-side, but nothing previously pruned + // the client-side eventsStore — an already-loaded Event for a deleted + // Calendar stayed in the grid, rendered gray (its Calendar no longer + // resolves), until a full page reload re-fetched Events from scratch. + it("removes the deleted calendar's events immediately, without a reload", async () => { + render( + , + ); + + await screen.findByText(/1 calendar came in from this account/); + await userEvent.click(screen.getByRole("radio", { name: /Delete the calendars/ })); + await userEvent.click(screen.getByRole("button", { name: "Disconnect and delete" })); + + await waitFor(() => expect(connectionsApi.disconnect).toHaveBeenCalledWith("token-123", 1, "delete")); + expect( + useEventsStore.getState().events.some((event) => event.calendarId === "cal-1"), + ).toBe(false); + expect(useShellStore.getState().checkedCalendarIds.has("cal-1")).toBe(false); + }); +}); diff --git a/src/settings/DisconnectConnectionModal.tsx b/src/settings/DisconnectConnectionModal.tsx index 21fcccf..ce659ce 100644 --- a/src/settings/DisconnectConnectionModal.tsx +++ b/src/settings/DisconnectConnectionModal.tsx @@ -4,6 +4,8 @@ import { Radio } from "@base-ui/react/radio"; import { RadioGroup } from "@base-ui/react/radio-group"; import { useConnectionsStore } from "../lib/connectionsStore"; import { useCalendarsStore } from "../lib/calendarsStore"; +import { useEventsStore } from "../lib/eventsStore"; +import { useShellStore } from "../lib/shellStore"; import { type DisconnectDisposition, type DisconnectImpact } from "../lib/connectionsApi"; import { errorMessage } from "../lib/errorMessage"; import { Button } from "../components/ui/Button"; @@ -60,6 +62,20 @@ export function DisconnectConnectionModal({ setError(null); try { await disconnect(connectionId, disposition); + // A "delete" disposition has already removed these Calendars and their + // Events server-side — but an Event already loaded into eventsStore + // doesn't know that, and its Calendar no longer resolving is what + // renders it gray (calendarColors.ts's UNRESOLVED_CALENDAR_COLOR + // fallback) instead of dropping it, same failure deleteCalendarCascade + // and leaveCalendarCascade exist to avoid for their own delete paths. + // No rollback needed here, unlike those two: disconnect() has already + // succeeded by this point, so there's nothing to revert to. + if (disposition === "delete") { + for (const calendar of linkedCalendars) { + useEventsStore.getState().removeEventsByCalendarId(calendar.id); + useShellStore.getState().removeCheckedCalendarId(calendar.id); + } + } // A "delete" disposition removes Calendars from the sidebar; a "keep" // one turns them into ordinary owned Calendars. Either way the // sidebar's Connection heading is gone, so re-fetch — best-effort, From 5cf5766bce592cf2039d0fc6d7ef14a564407ffc Mon Sep 17 00:00:00 2001 From: XiovV Date: Thu, 10 Sep 2026 16:12:47 +0200 Subject: [PATCH 02/33] Exposure: a Calendar's CalDAV home-set membership is per-User (ADR-0080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes ADR-0074's unconditional Linked-Calendar hiding with a per-(calendar, user) Exposure choice: a Linked Calendar's own Owner defaults to unexposed, a Workspace Member it's Shared to defaults to exposed, and either default is overridable. Every direct CalDAV path that resolves a Calendar off its request URL — PROPFIND, calendar-query, calendar-multiget, sync-collection, and PROPPATCH — now consults it, not just the home-set listing. Adds calendar_exposures (mirroring calendar_user_colors), a PUT /api/calendars/{id}/exposure endpoint, a sidebar row-menu toggle for Linked Calendars, and a first-App-Password nudge reusing ADR-0027's pattern. Read-only exposure only; write-back over CalDAV is #299. Fixes #297 --- CONTEXT.md | 6 +- ...d-calendars-are-not-exposed-over-caldav.md | 2 +- ...-caldav-home-set-membership-is-per-user.md | 41 ++++ server/internal/caldavserver/calendars.go | 29 +-- .../caldavserver/exposure_direct_path_test.go | 109 ++++++++++ .../linked_calendar_hidden_test.go | 198 +++++++++++++++--- server/internal/caldavserver/object.go | 14 +- .../caldavserver/propfind_privilege_test.go | 84 ++++++++ server/internal/caldavserver/proppatch.go | 25 ++- server/internal/caldavserver/query.go | 24 +++ server/internal/caldavserver/sync.go | 19 ++ .../db/migrations/00007_calendar_exposure.sql | 18 ++ server/internal/handlers/calendar.go | 17 +- server/internal/handlers/calendar_exposure.go | 45 ++++ .../handlers/calendar_exposure_test.go | 126 +++++++++++ .../internal/handlers/connection_calendars.go | 7 +- server/internal/handlers/share_test.go | 1 + .../internal/repository/calendar_exposure.go | 68 ++++++ server/internal/repository/event.go | 8 +- server/internal/router/router.go | 6 + server/internal/service/calendar.go | 161 +++++++++++--- server/internal/service/event_series.go | 18 +- .../internal/service/event_writeback_test.go | 13 +- server/internal/service/graph.go | 4 +- src/components/layout/CalendarList.test.tsx | 73 ++++++- src/components/layout/CalendarList.tsx | 24 ++- src/lib/calendar.ts | 5 + src/lib/calendarsApi.ts | 17 ++ src/lib/calendarsStore.ts | 24 +++ src/settings/AppPasswordsSection.tsx | 24 +++ 30 files changed, 1103 insertions(+), 107 deletions(-) create mode 100644 docs/adr/0080-exposure-caldav-home-set-membership-is-per-user.md create mode 100644 server/internal/caldavserver/exposure_direct_path_test.go create mode 100644 server/internal/db/migrations/00007_calendar_exposure.sql create mode 100644 server/internal/handlers/calendar_exposure.go create mode 100644 server/internal/handlers/calendar_exposure_test.go create mode 100644 server/internal/repository/calendar_exposure.go diff --git a/CONTEXT.md b/CONTEXT.md index 1d48e5b..3351019 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -267,6 +267,10 @@ _Avoid_: attachment id (they are the same thing — say Managed ID), handle, tok ## Sync +**Exposure**: +One User's own answer to whether a Calendar appears in *their* CalDAV home-set — resolved per User rather than stored per Calendar, so the same Calendar can sit in one accessor's home-set while staying absent from its own Owner's, or from another accessor's. Every Calendar accepts an explicit choice in either direction; absent one, the default is present, except a Linked Calendar's own Owner, who defaults to absent. Resolved only after Access, never in its place — a Calendar the User can no longer read is never exposed regardless of what this holds, and a Share ending clears it the same way it clears a colour override. See ADR-0080. +_Avoid_: visibility, sharing, publishing, device sync + **Calendar object**: The unit CalDAV addresses — one `.ics` resource per Event *series*, sharing a single iCalendar `UID` (the Master's id) and bundling the Master, all its Overrides, and its Exceptions in one file. The CalDAV-visible counterpart to the many rows a series occupies and to the Occurrences it expands to: one Calendar object, one series, many rows. Exposed at `{masterId}.ics`. Its Attachments are referenced by `ATTACH`, never carried inside it. See ADR-0025, ADR-0040. _Avoid_: calendar resource, ics file, vevent @@ -308,7 +312,7 @@ One User's authorized link to one account at one Provider, carrying the tokens, _Avoid_: account link, integration, OAuth grant (that is the mechanism, not the thing), provider account **Linked Calendar**: -A Calendar mirrored from one calendar of a Connection. An ordinary Calendar in every respect a User can see, distinguished by what stands behind it: a Connection the connecting User authorized, not a URL anyone can paste. Owned by that User even when the underlying calendar was merely shared to them at the Provider — Access resolves normally and is then clamped by the Source's mode, so a calendar the Provider will not accept writes to is read-only here for its Owner too. Shareable to Workspace Members like any other Calendar, but a Share on it is clamped to Viewer: an Editor's write would run as the connecting User's own Provider identity, so the Editor Role is refused at creation, not silently downgraded. Absent from **every** principal's CalDAV home-set — its Owner's and that of a Member it was Shared to alike — unconditionally, since the Owner almost certainly syncs that calendar natively already and a viewer-dependent rule would pass every test written from the Owner's seat (ADR-0074, superseding ADR-0054). What it looks like is this instance's business, not the Provider's: its Name follows the Provider's own renames until someone renames it here, reusing a Subscription's shadow-column mechanism verbatim (ADR-0032); its Calendar color is seeded from the Provider once at import and is this instance's afterward, with no publisher value left to track beneath it. See ADR-0052, ADR-0074, ADR-0075. +A Calendar mirrored from one calendar of a Connection. An ordinary Calendar in every respect a User can see, distinguished by what stands behind it: a Connection the connecting User authorized, not a URL anyone can paste. Owned by that User even when the underlying calendar was merely shared to them at the Provider — Access resolves normally and is then clamped by the Source's mode, so a calendar the Provider will not accept writes to is read-only here for its Owner too. Shareable to Workspace Members like any other Calendar, but a Share on it is clamped to Viewer: an Editor's write would run as the connecting User's own Provider identity, so the Editor Role is refused at creation, not silently downgraded. Its CalDAV home-set membership is governed by Exposure (ADR-0080): its own Owner defaults to absent, since the Owner almost certainly syncs that calendar natively at the Provider already, while a Member it was Shared to defaults to present, having nothing of their own to duplicate — either default is overridable per User. What it looks like is this instance's business, not the Provider's: its Name follows the Provider's own renames until someone renames it here, reusing a Subscription's shadow-column mechanism verbatim (ADR-0032); its Calendar color is seeded from the Provider once at import and is this instance's afterward, with no publisher value left to track beneath it. See ADR-0052, ADR-0075, ADR-0080. _Avoid_: mirrored calendar, connected calendar (too close to Connection), Google calendar, external calendar **Refresh**: diff --git a/docs/adr/0074-linked-calendars-are-not-exposed-over-caldav.md b/docs/adr/0074-linked-calendars-are-not-exposed-over-caldav.md index db059a2..493e575 100644 --- a/docs/adr/0074-linked-calendars-are-not-exposed-over-caldav.md +++ b/docs/adr/0074-linked-calendars-are-not-exposed-over-caldav.md @@ -1,6 +1,6 @@ # A Linked Calendar is not exposed over CalDAV at all -Status: accepted — supersedes ADR-0054 +Status: superseded by ADR-0080 A Linked Calendar appears in **no** principal's CalDAV home-set: not its Owner's, not that of a Workspace Member it was Shared to. `ListCalendars` filters out every Calendar whose Source is a Connection, unconditionally. diff --git a/docs/adr/0080-exposure-caldav-home-set-membership-is-per-user.md b/docs/adr/0080-exposure-caldav-home-set-membership-is-per-user.md new file mode 100644 index 0000000..586ef09 --- /dev/null +++ b/docs/adr/0080-exposure-caldav-home-set-membership-is-per-user.md @@ -0,0 +1,41 @@ +# Exposure: a Calendar's CalDAV home-set membership becomes a per-User choice + +Status: accepted — supersedes ADR-0074, reintroducing ADR-0054's viewer-dependent design + +Whether a Calendar appears in a User's own CalDAV home-set is now that User's own choice, resolved per `(calendar_id, user_id)` from `calendar_exposures` — a table reusing `calendar_user_colors`' shape verbatim (ADR-0038): no indirection, cascading on both Calendar and User. Absent a row, the default is exposed, except a Linked Calendar's own Owner, who defaults to unexposed. The home-set filter and the direct-path lookup both consult it for **every** Calendar, not only Connection-sourced ones, even though only a Linked Calendar gets a UI toggle today (Not in scope, below). Read-only for now — Exposure surviving a device's own write is #299. + +## Why the cost ADR-0074 refused to pay is already paid + +ADR-0074 rejected ADR-0054's viewer-dependent home-set for one named reason: it would make `ListCalendars` "a function of the requesting principal, not just of the Calendar set," and landing that in the same release as write-back, conflict handling, and the series mapping risked a quiet failure mode — a home-set correct for the Owner and wrong for an accessor, passing every test written from the Owner's seat alone. ADR-0054 had named the same cost itself and bet that #163's Invitations collection would pay it first. + +That bet paid off before this ADR needed it to. #163 shipped a principal-dependent `ListCalendars` for its own, unrelated reason: `caldavserver/calendars.go`'s `ListCalendars` already runs `ListAccessible` and `ListAttendeeOnlySeries` side by side and appends a synthetic Invitations collection only for a principal who actually holds an Attendee-only Event. The shape ADR-0074 was trying to avoid being first to introduce has already shipped, under a different name, for a different reason — reintroducing it for Exposure adds a second branch to code that already has one, not a new kind of risk to the codebase. + +This is why this reads as a return to ADR-0054 rather than a reversal of ADR-0074's taste: the thing ADR-0074 refused to be first to risk is a bill this codebase already paid. + +## What's actually different from ADR-0054's design + +ADR-0054 specified a Calendar-scoped toggle, overridable "per Linked Calendar" — a column or sibling row keyed on the Calendar alone would have made sense for a single-User instance, but conflates two different people's answers to two different questions: the Owner's own preference and a Share-holder's own preference are not the same fact, and a Workspace can hold several of the latter for one Calendar. Exposure is keyed `(calendar_id, user_id)` instead, one independent answer per person who can see the Calendar at all — the shape `calendar_user_colors` already established for exactly this reason (ADR-0038). + +## Decision + +- `calendar_exposures(calendar_id, user_id, exposed)`, keyed and cascaded exactly like `calendar_user_colors`. Lands as a new append-only migration (`00007_calendar_exposure.sql`) rather than hand-edited into `00001_init.sql`, matching how `00002`–`00006` already added onto the last squash instead of folding into it — ADR-0048's periodic squash is a deliberate, occasional act, not something every schema change since the last one performs. Editing `00001_init.sql` directly would silently strand any database that already applied it through version 6: goose tracks applied versions by number, not content, so it would never re-run a changed version 1. +- Absent a row, the default resolves as: ordinary Calendar → exposed; Subscribed Calendar → exposed (ADR-0032 unchanged); Linked Calendar, requesting principal is Owner → not exposed; Linked Calendar, requesting principal holds a Share → exposed. +- The home-set filter (`ListCalendars`) and every entry point that resolves a Calendar straight off a request path — `GetCalendar` (PROPFIND), calendar-query, calendar-multiget, sync-collection, and PROPPATCH — resolve Exposure for every Calendar the caller can otherwise reach, mirroring the shape #163 established for the Invitations collection: not exposed reads as absent from the listing and 404 on a guessed, bookmarked, or already-synced collection URL, identically. A device that discovered and cached the collection before Exposure was turned off must lose it there too, not just on its next PROPFIND. +- **Access resolves first, Exposure second.** A Calendar the caller has no Access to is never in the accessible set Exposure is even asked about, so a revoked Share drops it regardless of what any leftover Exposure row says. `RevokeShare` and `LeaveShare` clear the caller's own Exposure row alongside their colour override, for the same reason ADR-0038 gives: a choice with no Access behind it would otherwise linger, invisible, until Access was ever granted again. +- The setting is read/write on its own endpoint (`PUT /api/calendars/{id}/exposure`), open to any User with Access — Owner and accessor alike — writing only the caller's own row. This is *not* folded into the general Calendar-update endpoint the way ADR-0038's colour override is: colour has an Owner's-own-value to fall back on when the caller isn't the Owner, and Exposure has none — every principal, Owner included, only ever writes their own row, so there is no owner-write/accessor-override branch to reuse. +- The UI toggle lives in a Linked Calendar's existing sidebar row menu, the one surface both the Owner and an accessor already reach — not Settings → Connections, where an accessor has no Connection to find it under and the Owner's own seat would default to on, which is backwards. Its label speaks in terms of devices, never CalDAV. +- The first App Password a User creates nudges them toward Exposure if they own a Linked Calendar still on the unexposed default — the same moment and pattern ADR-0027 established for the reminder-delivery toggle: the point where a User first demonstrates they have a device to expose *to*. + +## Considered Options + +- **Reintroduce ADR-0054's viewer-dependent defaults, now that #163 already pays the shared cost (chosen).** +- **Keep ADR-0074's blanket hide.** Cheapest to leave alone, but leaves the accessor's phone permanently empty and gives the Owner who has dropped native Google sync no way back onto CalDAV at all. +- **A per-Connection bulk toggle instead of a per-User, per-Calendar row.** Coarser and cheaper to build, but the wrong shape: the Owner's own preference and an accessor's own preference are different people answering different questions about the same Calendar, and one switch on the Connection can't hold both independently. + +## Consequences + +- **The accessor gap ADR-0074 accepted is closed.** A Workspace Member Shared a Linked Calendar now sees it on their phone by default, with no toggle to find and no dependency on the Owner doing anything on their behalf. +- **The Owner who has moved off native Google sync gets their own Linked Calendar back on CalDAV**, discoverable at the moment (first App Password) where its absence would otherwise be a silent surprise. +- **`linked_calendar_hidden_test.go`'s accessor-seat assertions invert**: the Calendar that test used to prove was absent from a Shared Member's home-set is now proven present by default, while the Owner's own exclusion — the same test file's other half — stays true as a default rather than an unconditional rule. +- **Ordinary and Subscribed Calendars carry Exposure too, with no UI to set it.** Accepted rather than special-cased away: the resolution logic asks the same question of every Calendar, so a future ADR can turn on a toggle for them without touching this one again. +- **The read-only CalDAV privilege set for a Linked Calendar, dead code since ADR-0074, is live again** for exactly the principals Exposure lets reach a collection at all — asserted directly, per #297's acceptance criteria, since it has never actually run against a Linked Calendar before now. diff --git a/server/internal/caldavserver/calendars.go b/server/internal/caldavserver/calendars.go index 9ec5229..301e73e 100644 --- a/server/internal/caldavserver/calendars.go +++ b/server/internal/caldavserver/calendars.go @@ -59,13 +59,15 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) result := make([]caldav.Calendar, 0, len(calendars)) for _, c := range calendars { - // A Linked Calendar is absent from every principal's home-set, - // unconditionally — not just its Owner's (ADR-0074, superseding - // ADR-0054): the connecting User almost certainly already syncs - // Google natively, and a Workspace Member it was Shared to gets the - // same exclusion rather than a viewer-dependent rule that would pass - // every test written from the Owner's seat alone. - if c.Source != nil && c.Source.Kind == repository.SourceKindConnection { + // Exposure decides whether a Calendar belongs in userID's own + // CalDAV home-set at all (ADR-0080, superseding ADR-0074's + // unconditional exclusion): resolved per Calendar and per viewer + // already, above, so this is a straight filter rather than a + // Source-kind check. A Linked Calendar's own Owner defaults to + // unexposed and a Workspace Member it was Shared to defaults to + // exposed, but either default is overridable, and every other + // Calendar defaults to exposed exactly as before. + if !c.Exposed { continue } result = append(result, toCalDAVCalendar(userID, c.Calendar)) @@ -104,18 +106,19 @@ func (b *Backend) GetCalendar(ctx context.Context, path string) (*caldav.Calenda return &caldav.Calendar{Path: attendeeCollectionPath(userID), Name: attendeeCollectionName}, nil } - c, err := b.calendars.Get(ctx, userID, calendarID) + access, c, exposed, err := b.calendars.AccessWithExposure(ctx, userID, calendarID) if errors.Is(err, repository.ErrNotFound) { return nil, webdav.NewHTTPError(http.StatusNotFound, err) } if err != nil { return nil, fmt.Errorf("get calendar: %w", err) } - if c.Source != nil && c.Source.Kind == repository.SourceKindConnection { - // Mirrors ListCalendars' own exclusion (ADR-0074) — a stale or - // guessed URL to a Linked Calendar 404s exactly like one that never - // appeared in the home-set to begin with. - return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("linked calendar is not exposed over caldav")) + if !access.CanRead() || !exposed { + // Mirrors ListCalendars' own condition (ADR-0080) — a stale or + // guessed URL to a Calendar the caller can't read, or isn't exposed + // to, 404s exactly like one that never appeared in the home-set to + // begin with. + return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("calendar not found")) } result := toCalDAVCalendar(userID, c) diff --git a/server/internal/caldavserver/exposure_direct_path_test.go b/server/internal/caldavserver/exposure_direct_path_test.go new file mode 100644 index 0000000..6eefcca --- /dev/null +++ b/server/internal/caldavserver/exposure_direct_path_test.go @@ -0,0 +1,109 @@ +// exposure_direct_path_test.go covers ADR-0080's "Direct-path resolution +// stays mirrored with the listing" beyond PROPFIND itself: a client that +// already discovered and cached a Calendar's collection URL before Exposure +// was turned off must stop reading (or renaming) it through every other +// entry point that resolves the same path directly — calendar-query, +// calendar-multiget, a plain object GET, sync-collection, and PROPPATCH — +// not just re-discovering it via a fresh PROPFIND. +package caldavserver + +import ( + "context" + "testing" + "time" + + "github.com/emersion/go-ical" + "github.com/emersion/go-webdav/caldav" + "github.com/google/uuid" + + "github.com/XiovV/calich/server/internal/repository" + "github.com/XiovV/calich/server/internal/service" +) + +// hiddenAccessorLinkedCalendar sets up a Linked Calendar Shared to a +// Workspace Member who has explicitly turned Exposure off — the scenario +// where a device already cached the collection path before the toggle was +// flipped, one Event on it so a read path has something real to refuse. +func hiddenAccessorLinkedCalendar(t *testing.T) (env testCalDAVEnv, memberID int64, memberSecret, linkedID string, eventID string) { + t.Helper() + env = newTestCalDAVEnv(t) + memberID, memberSecret = env.addWorkspaceMember(t, "member") + + linkedID = uuid.NewString() + // Writable, purely so eventService.Create below is allowed to write the + // fixture Event through the ordinary (non-CalDAV) write path — the + // exposure checks under test read the resolved Calendar, not the Event. + env.createLinkedCalendarWithMode(t, linkedID, "Linked", repository.SourceModeWritable) + if _, _, err := env.calendarService.Share(context.Background(), env.userID, linkedID, "member@example.com", repository.RoleViewer); err != nil { + t.Fatalf("share linked calendar: %v", err) + } + if err := env.calendarService.SetExposure(context.Background(), memberID, linkedID, false); err != nil { + t.Fatalf("set exposure: %v", err) + } + + event, err := env.eventService.Create(context.Background(), env.userID, "evt-hidden-1", service.EventWrite{ + CalendarID: linkedID, Title: "Standup", + Start: time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC), End: time.Date(2026, 6, 1, 9, 30, 0, 0, time.UTC), + }) + if err != nil { + t.Fatalf("create event: %v", err) + } + return env, memberID, memberSecret, linkedID, event.ID +} + +func TestCalendarQuery_ExplicitlyHiddenLinkedCalendar_Returns404(t *testing.T) { + env, memberID, memberSecret, linkedID, _ := hiddenAccessorLinkedCalendar(t) + + client := newTestCalDAVClientAs(t, env, "member@example.com", memberSecret) + _, err := client.QueryCalendar(context.Background(), calendarPath(memberID, linkedID), &caldav.CalendarQuery{ + CompFilter: caldav.CompFilter{Name: ical.CompCalendar, Comps: []caldav.CompFilter{{Name: ical.CompEvent}}}, + }) + if err == nil { + t.Fatalf("expected calendar-query against an explicitly-hidden Linked Calendar to fail") + } +} + +func TestCalendarMultiget_ExplicitlyHiddenLinkedCalendar_Returns404(t *testing.T) { + env, memberID, memberSecret, linkedID, eventID := hiddenAccessorLinkedCalendar(t) + + client := newTestCalDAVClientAs(t, env, "member@example.com", memberSecret) + objPath := calendarObjectPath(memberID, linkedID, eventID) + _, err := client.MultiGetCalendar(context.Background(), calendarPath(memberID, linkedID), &caldav.CalendarMultiGet{ + Paths: []string{objPath}, + }) + if err == nil { + t.Fatalf("expected calendar-multiget against an explicitly-hidden Linked Calendar to fail") + } +} + +func TestGetCalendarObject_ExplicitlyHiddenLinkedCalendar_Returns404(t *testing.T) { + env, memberID, memberSecret, linkedID, eventID := hiddenAccessorLinkedCalendar(t) + + client := newTestCalDAVClientAs(t, env, "member@example.com", memberSecret) + _, err := client.GetCalendarObject(context.Background(), calendarObjectPath(memberID, linkedID, eventID)) + if err == nil { + t.Fatalf("expected GET of an object on an explicitly-hidden Linked Calendar to fail") + } +} + +func TestSyncCollection_ExplicitlyHiddenLinkedCalendar_Returns404(t *testing.T) { + env, memberID, memberSecret, linkedID, _ := hiddenAccessorLinkedCalendar(t) + + resp := report(t, env.srv, calendarPath(memberID, linkedID), "member@example.com", memberSecret, syncCollectionInitial) + defer resp.Body.Close() + + if resp.StatusCode != 404 { + t.Fatalf("expected 404 for sync-collection against an explicitly-hidden Linked Calendar, got %d", resp.StatusCode) + } +} + +func TestPropPatch_ExplicitlyHiddenLinkedCalendar_Returns404(t *testing.T) { + env, memberID, memberSecret, linkedID, _ := hiddenAccessorLinkedCalendar(t) + + resp := proppatch(t, env.srv, calendarPath(memberID, linkedID), "member@example.com", memberSecret, proppatchSetDisplayName("Hijacked")) + defer resp.Body.Close() + + if resp.StatusCode != 404 { + t.Fatalf("expected 404 for PROPPATCH against an explicitly-hidden Linked Calendar, got %d", resp.StatusCode) + } +} diff --git a/server/internal/caldavserver/linked_calendar_hidden_test.go b/server/internal/caldavserver/linked_calendar_hidden_test.go index e6d194d..7644c93 100644 --- a/server/internal/caldavserver/linked_calendar_hidden_test.go +++ b/server/internal/caldavserver/linked_calendar_hidden_test.go @@ -14,11 +14,21 @@ import ( // createLinkedCalendar creates a Linked Calendar in env's own Workspace, // backed by a freshly-inserted Connection row to satisfy calendar_sources' -// foreign key — the ADR-0074 fixture: a Calendar whose Source is -// Connection-kind must never reach a CalDAV home-set, unlike every other -// kind this package's other PROPFIND tests exercise. +// foreign key, read-only at the Source (ADR-0080's default resolution only +// looks at Source.Kind, so mode is incidental here — createLinkedCalendarWithMode +// is the variant that varies it). func (env testCalDAVEnv) createLinkedCalendar(t *testing.T, id, name string) { t.Helper() + env.createLinkedCalendarWithMode(t, id, name, repository.SourceModeReadOnly) +} + +// createLinkedCalendarWithMode is createLinkedCalendar's mode-parameterized +// sibling, for a test asserting the CalDAV privilege set a writable Linked +// Calendar's Owner gets versus a read-only one's (ADR-0075, ADR-0080) — the +// mode has never varied in this package before Exposure gave a Linked +// Calendar's own Owner a reason to actually reach its collection. +func (env testCalDAVEnv) createLinkedCalendarWithMode(t *testing.T, id, name string, mode repository.SourceMode) { + t.Helper() connections := repository.NewConnectionRepository(env.db) conn, err := connections.Upsert(context.Background(), env.userID, repository.ProviderGoogle, "someone@gmail.com", repository.ConnectionFields{ @@ -35,7 +45,7 @@ func (env testCalDAVEnv) createLinkedCalendar(t *testing.T, id, name string) { Name: name, Color: "#12809CFF", }, repository.SourceFields{ Kind: repository.SourceKindConnection, - Mode: repository.SourceModeReadOnly, + Mode: mode, ConnectionID: &conn.ID, ExternalCalendarID: &externalID, }); err != nil { @@ -43,11 +53,15 @@ func (env testCalDAVEnv) createLinkedCalendar(t *testing.T, id, name string) { } } -// TestPropfind_HomeSet_ExcludesLinkedCalendar covers ADR-0074's "a Linked -// Calendar appears in no principal's CalDAV home-set" for the Owner's own -// side — the ordinary Calendar env.calendarID ("cal-1") is unaffected and -// still appears, exactly like TestPropfind_HomeSet_ListsOneCollectionPerCalendar. -func TestPropfind_HomeSet_ExcludesLinkedCalendar(t *testing.T) { +// TestPropfind_HomeSet_ExcludesLinkedCalendarFromOwnerByDefault covers +// ADR-0080's default for a Linked Calendar's own Owner: absent an explicit +// Exposure choice, it stays out of their own CalDAV home-set — the +// connecting User almost certainly already syncs it natively at the +// Provider, unchanged from ADR-0074's reasoning even though the rule is no +// longer unconditional. The ordinary Calendar env.calendarID ("cal-1") is +// unaffected and still appears, exactly like +// TestPropfind_HomeSet_ListsOneCollectionPerCalendar. +func TestPropfind_HomeSet_ExcludesLinkedCalendarFromOwnerByDefault(t *testing.T) { env := newTestCalDAVEnv(t) env.createLinkedCalendar(t, uuid.NewString(), "Linked") @@ -57,7 +71,7 @@ func TestPropfind_HomeSet_ExcludesLinkedCalendar(t *testing.T) { body := readBody(t, resp) if strings.Contains(body, "Linked") { - t.Fatalf("expected the Linked Calendar to be absent from the home-set, got:\n%s", body) + t.Fatalf("expected the Linked Calendar to be absent from its Owner's home-set by default, got:\n%s", body) } wantCollection := fmt.Sprintf("/dav/%d/calendars/%s/", env.userID, env.calendarID) if !strings.Contains(body, wantCollection) { @@ -65,22 +79,21 @@ func TestPropfind_HomeSet_ExcludesLinkedCalendar(t *testing.T) { } } -// TestPropfind_HomeSet_ExcludesLinkedCalendarFromAccessorsSeat covers -// ADR-0074's "a Linked Calendar appears in no principal's CalDAV home-set" -// asserted from the accessor's seat rather than the Owner's — the exact -// failure mode of ADR-0054's superseded rule, where a home-set correct for -// the Owner was wrong for a Workspace Member it was Shared to. The Member is -// Shared both an ordinary Calendar and the Linked one; only the ordinary one -// reaches their home-set. -func TestPropfind_HomeSet_ExcludesLinkedCalendarFromAccessorsSeat(t *testing.T) { +// TestPropfind_HomeSet_IncludesLinkedCalendarInAccessorsSeatByDefault covers +// ADR-0080's default for a Workspace Member a Linked Calendar was Shared +// to: present by default in their own home-set, since they have no native +// Provider copy of it to duplicate. This is the inverted acceptance +// criterion ADR-0074 (and this test, before Exposure) asserted the opposite +// of — the single most important behavioural change #297 makes. The Member +// is Shared both an ordinary Calendar and the Linked one; both reach their +// home-set. +func TestPropfind_HomeSet_IncludesLinkedCalendarInAccessorsSeatByDefault(t *testing.T) { env := newTestCalDAVEnv(t) memberID, memberSecret := env.addWorkspaceMember(t, "member") linkedID := uuid.NewString() env.createLinkedCalendar(t, linkedID, "Linked") - // The ordinary Calendar is Shared to the Member as a control: their - // home-set is not simply empty of everything. if _, _, err := env.calendarService.Share(context.Background(), env.userID, env.calendarID, "member@example.com", repository.RoleViewer); err != nil { t.Fatalf("share ordinary calendar: %v", err) } @@ -93,19 +106,114 @@ func TestPropfind_HomeSet_ExcludesLinkedCalendarFromAccessorsSeat(t *testing.T) defer resp.Body.Close() body := readBody(t, resp) - if strings.Contains(body, "Linked") { - t.Fatalf("expected the Shared Linked Calendar to be absent from the accessor's home-set, got:\n%s", body) + wantOrdinary := fmt.Sprintf("/dav/%d/calendars/%s/", memberID, env.calendarID) + wantLinked := fmt.Sprintf("/dav/%d/calendars/%s/", memberID, linkedID) + if !strings.Contains(body, wantOrdinary) { + t.Fatalf("expected the ordinary Shared calendar to be listed at the accessor's own path, got:\n%s", body) + } + if !strings.Contains(body, wantLinked) { + t.Fatalf("expected the Shared Linked Calendar to be present in the accessor's home-set by default, got:\n%s", body) } - wantCollection := fmt.Sprintf("/dav/%d/calendars/%s/", memberID, env.calendarID) +} + +// TestPropfind_HomeSet_OwnerExplicitExposureOverridesDefault covers an +// explicit Exposure choice overriding the default in the "on" direction +// (ADR-0080's acceptance criteria): the Owner who has moved off native +// Provider sync can turn their own Linked Calendar back on. +func TestPropfind_HomeSet_OwnerExplicitExposureOverridesDefault(t *testing.T) { + env := newTestCalDAVEnv(t) + linkedID := uuid.NewString() + env.createLinkedCalendar(t, linkedID, "Linked") + + if err := env.calendarService.SetExposure(context.Background(), env.userID, linkedID, true); err != nil { + t.Fatalf("set exposure: %v", err) + } + + homeSetPath := fmt.Sprintf("/dav/%d/calendars/", env.userID) + resp := propfind(t, env.srv, homeSetPath, "admin@example.com", env.appPasswordSecret, "1", propfindDisplayName) + defer resp.Body.Close() + + body := readBody(t, resp) + wantCollection := fmt.Sprintf("/dav/%d/calendars/%s/", env.userID, linkedID) if !strings.Contains(body, wantCollection) { - t.Fatalf("expected the ordinary Shared calendar to still be listed at the accessor's own path, got:\n%s", body) + t.Fatalf("expected an explicit Exposure override to include the Owner's own Linked Calendar, got:\n%s", body) + } +} + +// TestPropfind_HomeSet_AccessorExplicitExposureOverridesDefault covers the +// same override in the "off" direction: a Workspace Member who doesn't want +// a Shared Linked Calendar cluttering their devices can turn it off, even +// though it defaults to on for them. +func TestPropfind_HomeSet_AccessorExplicitExposureOverridesDefault(t *testing.T) { + env := newTestCalDAVEnv(t) + memberID, memberSecret := env.addWorkspaceMember(t, "member") + + linkedID := uuid.NewString() + env.createLinkedCalendar(t, linkedID, "Linked") + if _, _, err := env.calendarService.Share(context.Background(), env.userID, linkedID, "member@example.com", repository.RoleViewer); err != nil { + t.Fatalf("share linked calendar: %v", err) + } + if err := env.calendarService.SetExposure(context.Background(), memberID, linkedID, false); err != nil { + t.Fatalf("set exposure: %v", err) + } + + homeSetPath := fmt.Sprintf("/dav/%d/calendars/", memberID) + resp := propfind(t, env.srv, homeSetPath, "member@example.com", memberSecret, "1", propfindDisplayName) + defer resp.Body.Close() + + body := readBody(t, resp) + if strings.Contains(body, "Linked") { + t.Fatalf("expected an explicit Exposure override to exclude the accessor's own Shared Linked Calendar, got:\n%s", body) + } +} + +// TestPropfind_HomeSet_RevokedShareWinsOverLeftoverExposureRow covers +// "Access resolves first, Exposure second" (ADR-0080's acceptance +// criteria): a stray Exposure row saying "show it" must never resurrect a +// Calendar the caller's Access to was revoked. RevokeShare already clears +// this row along the ordinary path (mirroring ADR-0038's colour-override +// cleanup); the row is recreated directly against the repository here so +// the assertion is about resolution order, not about that cleanup happening +// to run. +func TestPropfind_HomeSet_RevokedShareWinsOverLeftoverExposureRow(t *testing.T) { + env := newTestCalDAVEnv(t) + memberID, memberSecret := env.addWorkspaceMember(t, "member") + + linkedID := uuid.NewString() + env.createLinkedCalendar(t, linkedID, "Linked") + if _, _, err := env.calendarService.Share(context.Background(), env.userID, linkedID, "member@example.com", repository.RoleViewer); err != nil { + t.Fatalf("share linked calendar: %v", err) + } + if err := env.calendarService.RevokeShare(context.Background(), env.userID, linkedID, memberID); err != nil { + t.Fatalf("revoke share: %v", err) + } + + exposures := repository.NewCalendarExposureRepository(env.db) + if err := exposures.Upsert(context.Background(), memberID, linkedID, true); err != nil { + t.Fatalf("upsert stray exposure row: %v", err) + } + + homeSetPath := fmt.Sprintf("/dav/%d/calendars/", memberID) + resp := propfind(t, env.srv, homeSetPath, "member@example.com", memberSecret, "1", propfindDisplayName) + defer resp.Body.Close() + + body := readBody(t, resp) + if strings.Contains(body, "Linked") { + t.Fatalf("expected a revoked Share to exclude the Calendar despite a stray Exposure row saying exposed, got:\n%s", body) + } + + path := fmt.Sprintf("/dav/%d/calendars/%s/", memberID, linkedID) + directResp := propfind(t, env.srv, path, "member@example.com", memberSecret, "0", propfindDisplayName) + defer directResp.Body.Close() + if directResp.StatusCode != 404 { + t.Fatalf("expected 404 on the direct path despite the stray Exposure row, got %d", directResp.StatusCode) } } -// TestPropfind_HomeSet_StillListsSubscribedCalendar guards the ADR-0074 -// filter against over-reach: it keys on the Source's Kind being Connection, -// never merely on a Source existing, so a Subscribed Calendar in the same -// home-set stays exposed over CalDAV exactly as ADR-0032 established. +// TestPropfind_HomeSet_StillListsSubscribedCalendar guards Exposure's +// default resolution against over-reach: a Subscribed Calendar defaults to +// exposed for everyone, Owner included, exactly as ADR-0032 established — +// only a Linked Calendar's own Owner gets the unexposed default. func TestPropfind_HomeSet_StillListsSubscribedCalendar(t *testing.T) { env := newTestCalDAVEnv(t) @@ -132,9 +240,10 @@ func TestPropfind_HomeSet_StillListsSubscribedCalendar(t *testing.T) { } } -// TestPropfind_LinkedCalendar_DirectPathReturnsNotFound covers ADR-0074's -// exclusion holding even for a stale or guessed URL, mirroring ListCalendars' -// own condition rather than just hiding the collection from a listing. +// TestPropfind_LinkedCalendar_DirectPathReturnsNotFound covers ADR-0080's +// default exclusion holding even for a stale or guessed URL, mirroring +// ListCalendars' own condition rather than just hiding the collection from +// a listing. func TestPropfind_LinkedCalendar_DirectPathReturnsNotFound(t *testing.T) { env := newTestCalDAVEnv(t) linkedID := uuid.NewString() @@ -148,3 +257,30 @@ func TestPropfind_LinkedCalendar_DirectPathReturnsNotFound(t *testing.T) { t.Fatalf("expected 404 for a Linked Calendar's direct path, got %d", resp.StatusCode) } } + +// TestPropfind_LinkedCalendar_AccessorExplicitlyHiddenDirectPathReturnsNotFound +// covers "not exposed ⇒ 404 on the direct path, matching the listing" +// (ADR-0080's acceptance criteria) for an explicit override rather than a +// default: an accessor who turned their own Shared Linked Calendar off gets +// the same 404 a stale URL would. +func TestPropfind_LinkedCalendar_AccessorExplicitlyHiddenDirectPathReturnsNotFound(t *testing.T) { + env := newTestCalDAVEnv(t) + memberID, memberSecret := env.addWorkspaceMember(t, "member") + + linkedID := uuid.NewString() + env.createLinkedCalendar(t, linkedID, "Linked") + if _, _, err := env.calendarService.Share(context.Background(), env.userID, linkedID, "member@example.com", repository.RoleViewer); err != nil { + t.Fatalf("share linked calendar: %v", err) + } + if err := env.calendarService.SetExposure(context.Background(), memberID, linkedID, false); err != nil { + t.Fatalf("set exposure: %v", err) + } + + path := fmt.Sprintf("/dav/%d/calendars/%s/", memberID, linkedID) + resp := propfind(t, env.srv, path, "member@example.com", memberSecret, "0", propfindDisplayName) + defer resp.Body.Close() + + if resp.StatusCode != 404 { + t.Fatalf("expected 404 for an explicitly-hidden Linked Calendar's direct path, got %d", resp.StatusCode) + } +} diff --git a/server/internal/caldavserver/object.go b/server/internal/caldavserver/object.go index bd6f7fa..df24afb 100644 --- a/server/internal/caldavserver/object.go +++ b/server/internal/caldavserver/object.go @@ -118,12 +118,14 @@ func (b *Backend) currentObjectETag(ctx context.Context, userID int64, calendarI return false, "", nil } - // A Linked Calendar is not exposed over CalDAV (ADR-0074) — GetCalendar - // 404s the collection on discovery, so this is the same refusal for a - // client holding a cached or guessed object path: not-exists, so both the - // PUT precondition and DeleteCalendarObject treat it as a 404 rather than - // letting EventService.Delete's own writable-Linked-Calendar path run - // from a CalDAV request (#292). PutSeries takes the same posture directly. + // A Linked Calendar's object path answers not-exists here regardless of + // Exposure (ADR-0080) or the Source's mode: both the PUT precondition + // and DeleteCalendarObject must treat it as a 404 rather than letting + // EventService.Delete's own writable-Linked-Calendar path run from a + // CalDAV request, which would bypass Write-back entirely (#292). + // PutSeries takes the same posture directly. Exposure can make the + // collection itself discoverable (#297); accepting an object write or + // delete on it is #299's to build. cal, err := b.calendars.Get(ctx, userID, calendarID) if err != nil { if errors.Is(err, repository.ErrNotFound) { diff --git a/server/internal/caldavserver/propfind_privilege_test.go b/server/internal/caldavserver/propfind_privilege_test.go index c951c11..46473a3 100644 --- a/server/internal/caldavserver/propfind_privilege_test.go +++ b/server/internal/caldavserver/propfind_privilege_test.go @@ -1,9 +1,12 @@ package caldavserver import ( + "context" "strings" "testing" + "github.com/google/uuid" + "github.com/XiovV/calich/server/internal/repository" "github.com/XiovV/calich/server/internal/service" ) @@ -58,3 +61,84 @@ func TestPropfind_CurrentUserPrivilegeSet_SubscribedCalendarIsReadOnly(t *testin t.Fatalf("expected the privilege-set propstat to be 200 OK, got:\n%s", body) } } + +// TestPropfind_CurrentUserPrivilegeSet_LinkedCalendarOwnerWritableGrantsWrite +// covers a Linked Calendar's privilege set for its own Owner on a writable +// Source (ADR-0080's acceptance criteria): "expected to need no new code, +// since it already derives from Access, which clamps on the Source's mode" +// — asserted anyway, since it has never run for a Linked Calendar before +// Exposure gave its Owner a reason to reach the collection at all. The +// Owner defaults to unexposed (ADR-0080), so this needs an explicit +// Exposure override before the collection is reachable over CalDAV. +func TestPropfind_CurrentUserPrivilegeSet_LinkedCalendarOwnerWritableGrantsWrite(t *testing.T) { + env := newTestCalDAVEnv(t) + + linkedID := uuid.NewString() + env.createLinkedCalendarWithMode(t, linkedID, "Linked", repository.SourceModeWritable) + if err := env.calendarService.SetExposure(context.Background(), env.userID, linkedID, true); err != nil { + t.Fatalf("set exposure: %v", err) + } + + path := calendarPath(env.userID, linkedID) + resp := propfind(t, env.srv, path, "admin@example.com", env.appPasswordSecret, "0", propfindCurrentUserPrivilegeSet) + defer resp.Body.Close() + + body := readBody(t, resp) + if !strings.Contains(body, " ({ calendarsApi: { list: vi.fn() } })); +vi.mock("../../lib/calendarsApi", () => ({ + calendarsApi: { list: vi.fn(), setExposure: vi.fn(async () => true) }, +})); vi.mock("../../lib/connectionsApi", async () => { const actual = await vi.importActual("../../lib/connectionsApi"); return { @@ -25,6 +27,7 @@ const { useShellStore } = await import("../../lib/shellStore"); const { useWorkspacesStore } = await import("../../lib/workspacesStore"); const { useConnectionsStore } = await import("../../lib/connectionsStore"); const { connectionsApi } = await import("../../lib/connectionsApi"); +const { calendarsApi } = await import("../../lib/calendarsApi"); const { CalendarList } = await import("./CalendarList"); const owned: Calendar = { @@ -220,3 +223,71 @@ describe("Linked Calendar grouping", () => { expect(screen.queryByRole("menuitem", { name: "Export" })).not.toBeInTheDocument(); }); }); + +// #297, ADR-0080: Exposure's toggle lives in a Linked Calendar's row menu — +// the one surface both its Owner and an accessor it was Shared to reach. +describe("Linked Calendar Exposure toggle", () => { + const ownedLinked: Calendar = { + id: "cal-owned-linked", + name: "Work", + color: "#8E44ADFF", + access: "viewer", + isOwner: true, + sourceKind: "connection", + connectionId: 1, + connectionAccountEmail: "work@gmail.com", + exposed: false, + }; + const sharedLinked: Calendar = { + id: "cal-shared-linked", + name: "Colleague's Google", + color: "#3498DBFF", + access: "viewer", + isOwner: false, + ownerName: "Bob", + sourceKind: "connection", + connectionId: 3, + connectionAccountEmail: "bob@gmail.com", + exposed: true, + }; + + it("offers the toggle to the Linked Calendar's own Owner, reflecting the resolved default", async () => { + useCalendarsStore.setState({ calendars: [ownedLinked] }); + render(); + + await openMenu("Work"); + + const item = await screen.findByRole("menuitemcheckbox", { name: "Show on my devices" }); + expect(item).toHaveAttribute("aria-checked", "false"); + }); + + it("also offers the toggle to an accessor it was Shared to, reflecting their own resolved default", async () => { + useCalendarsStore.setState({ calendars: [owned, sharedLinked] }); + render(); + + await openMenu("Colleague's Google"); + + const item = await screen.findByRole("menuitemcheckbox", { name: "Show on my devices" }); + expect(item).toHaveAttribute("aria-checked", "true"); + }); + + it("omits the toggle on a Calendar that isn't Linked", async () => { + useCalendarsStore.setState({ calendars: [owned] }); + render(); + + await openMenu("Personal"); + + expect(screen.queryByRole("menuitemcheckbox", { name: "Show on my devices" })).not.toBeInTheDocument(); + }); + + it("writes the caller's own choice when toggled", async () => { + useCalendarsStore.setState({ calendars: [ownedLinked] }); + render(); + + await openMenu("Work"); + await userEvent.click(await screen.findByRole("menuitemcheckbox", { name: "Show on my devices" })); + + expect(calendarsApi.setExposure).toHaveBeenCalledWith("token-123", "cal-owned-linked", true); + expect(useCalendarsStore.getState().calendars.find((c) => c.id === "cal-owned-linked")?.exposed).toBe(true); + }); +}); diff --git a/src/components/layout/CalendarList.tsx b/src/components/layout/CalendarList.tsx index 1297cc6..e2edbcb 100644 --- a/src/components/layout/CalendarList.tsx +++ b/src/components/layout/CalendarList.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { formatDistanceToNow } from "date-fns"; import { Menu } from "@base-ui/react/menu"; -import { MoreVertical, Plus, TriangleAlert, Users } from "lucide-react"; +import { Check, MoreVertical, Plus, TriangleAlert, Users } from "lucide-react"; import { CalendarPickerModal } from "../../settings/CalendarPickerModal"; import { IconButton } from "../ui/IconButton"; import { iconButtonClasses } from "../ui/iconButtonClasses"; @@ -61,6 +61,7 @@ export function CalendarList() { (state) => state.toggleCalendarChecked, ); const refreshCalendar = useCalendarsStore((state) => state.refreshCalendar); + const setCalendarExposure = useCalendarsStore((state) => state.setCalendarExposure); // Linked Calendars group by Connection, so the sidebar needs the // Connections themselves to label each heading — it can no longer rely on // the account Email riding along on every Calendar, which only the list @@ -381,6 +382,27 @@ export function CalendarList() { Export )} + {/* Exposure (#297, ADR-0080): the one surface both the + Calendar's Owner and an accessor it was Shared to reach, + since an accessor has no Connection of their own under + Settings to find this beside. Speaks in terms of + devices, never CalDAV. */} + {isLinked && ( + setCalendarExposure(calendar.id, checked)} + closeOnClick={false} + className={menuItemClasses} + > + + + + Show on my devices + + )}
{canManage ? ( { + const response = await authedFetch(accessToken, `/api/calendars/${id}/exposure`, { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ exposed }), + }); + if (!response.ok) throw await errorFromResponse(response); + + return ((await response.json()) as { exposed: boolean }).exposed; + }, + async remove(accessToken: string, id: string): Promise { const response = await authedFetch(accessToken, `/api/calendars/${id}`, { method: "DELETE", diff --git a/src/lib/calendarsStore.ts b/src/lib/calendarsStore.ts index 1e0e7a2..b5ac4dd 100644 --- a/src/lib/calendarsStore.ts +++ b/src/lib/calendarsStore.ts @@ -29,6 +29,12 @@ interface CalendarsState { // updateCalendar, since the colour applied is exactly the one the caller // just picked, with no override-vs-fallback ambiguity to resolve. setCalendarColor: (id: string, color: string) => Promise; + // setCalendarExposure sets the caller's own Exposure choice on id (#297, + // ADR-0080), over its own endpoint — every caller writes their own row, + // Owner included, unlike setCalendarColor's Owner-vs-override split. + // Optimistic like setCalendarColor: the value applied is exactly the one + // the caller just chose, with nothing server-resolved to reconcile. + setCalendarExposure: (id: string, exposed: boolean) => Promise; // Resolves to whether the delete actually succeeded, so callers that // cascade other local state off of it (e.g. deleteCalendarCascade) know // whether to undo that cascade too. @@ -201,6 +207,24 @@ export const useCalendarsStore = create((set, get) => ({ }); }, + setCalendarExposure: async (id, exposed) => { + const previousCalendars = get().calendars; + await write({ + apply: () => + set((state) => ({ + calendars: state.calendars.map((calendar) => + calendar.id === id ? { ...calendar, exposed } : calendar, + ), + })), + revert: () => set({ calendars: previousCalendars }), + dispatch: async () => { + await calendarsApi.setExposure(requireAccessToken(), id, exposed); + }, + calendarId: id, + fallbackMessage: "Failed to update calendar exposure.", + }); + }, + removeCalendar: async (id) => { const previousCalendars = get().calendars; return write({ diff --git a/src/settings/AppPasswordsSection.tsx b/src/settings/AppPasswordsSection.tsx index d4800d5..1d559d3 100644 --- a/src/settings/AppPasswordsSection.tsx +++ b/src/settings/AppPasswordsSection.tsx @@ -6,6 +6,8 @@ import { Input } from "../components/ui/Input"; import { useAppPasswordsStore } from "../lib/appPasswordsStore"; import { useAsyncAction } from "../hooks/useAsyncAction"; import { errorMessage } from "../lib/errorMessage"; +import { useCalendarsStore } from "../lib/calendarsStore"; +import { canManageCalendar, isLinkedCalendar } from "../lib/calendar"; // The Settings page's App passwords section (#62, ADR-0024): lets a user // generate a per-device credential for native CalDAV clients, shown once with @@ -15,12 +17,21 @@ export function AppPasswordsSection() { const fetchAppPasswords = useAppPasswordsStore((state) => state.fetchAppPasswords); const createAppPassword = useAppPasswordsStore((state) => state.createAppPassword); const revokeAppPassword = useAppPasswordsStore((state) => state.revokeAppPassword); + // calendars only informs the Exposure nudge below (#297, ADR-0080) — this + // section otherwise has nothing to do with Calendars. + const calendars = useCalendarsStore((state) => state.calendars); const [label, setLabel] = useState(""); const [revealedSecret, setRevealedSecret] = useState(null); const [copied, setCopied] = useState(false); const [revokingId, setRevokingId] = useState(null); const [nudgeReminderDelivery, setNudgeReminderDelivery] = useState(false); + // nudgeExposure fires the same moment and follows the same one-shot + // "leaving this page means it won't be shown again" rule as + // nudgeReminderDelivery (ADR-0027's pattern, reused by #297/ADR-0080): a + // first App Password is the first proof this User has a device to expose + // a Linked Calendar to at all. + const [nudgeExposure, setNudgeExposure] = useState(false); const { isSubmitting, error, setError, run } = useAsyncAction(); useEffect(() => { @@ -34,6 +45,12 @@ export function AppPasswordsSection() { if (!label.trim()) return; const isFirstAppPassword = appPasswords.length === 0; + // Only a Linked Calendar this User owns and hasn't already exposed is + // worth nudging about — an ordinary or Subscribed Calendar has no + // unexposed default to surprise anyone with (#297, ADR-0080). + const hasUnexposedLinkedCalendar = calendars.some( + (calendar) => isLinkedCalendar(calendar) && canManageCalendar(calendar) && !calendar.exposed, + ); await run(async () => { const secret = await createAppPassword(label.trim()); @@ -41,6 +58,7 @@ export function AppPasswordsSection() { setCopied(false); setLabel(""); setNudgeReminderDelivery(isFirstAppPassword); + setNudgeExposure(isFirstAppPassword && hasUnexposedLinkedCalendar); }); } @@ -94,6 +112,12 @@ export function AppPasswordsSection() { page means it won't be shown again.

)} + {nudgeExposure && ( +

+ One of your linked calendars won't show up on this device yet — turn on "Show on my + devices" from that calendar's menu in the sidebar to bring it over. +

+ )}
)} From f94ce0834fe745dcb55464395bdedec1c98a0d39 Mon Sep 17 00:00:00 2001 From: XiovV Date: Thu, 10 Sep 2026 16:32:03 +0200 Subject: [PATCH 03/33] Make a Linked Calendar's Refresh-owned Event columns unwritable by a general update EventRepository.Update wrote ProviderEtag/RSVPStatus/ConferenceURL/ GuestCount/ProviderColor from whatever EventFields it was handed, with no merge against the stored row. EventService.Update papered over this with a carry-forward; upsertSeries (PutSeries, and Subscribed Calendar reconciliation) had none, so a CalDAV PUT reaching a Connection Source once #299 lands would silently null a Linked Calendar's Provider RSVP, conference link, guest count and etag. Remove the five columns from Update's SQL entirely, so no caller can touch them regardless of what it populates on EventFields, and delete the carry-forward it made unnecessary. They now move only through the three narrow writers built for exactly this (ApplyProviderOwnedFields, widened to also carry ProviderColor, UpdateProviderEtag, and AdoptProviderIdentity): upsertSeries calls the first two on its update branches when the caller legitimately owns fresh Provider data (a Refresh reconcile), and skips them otherwise (PutSeries, ImportSeries), so a plain edit can no longer blank this state by construction. Fixes #298 --- server/internal/repository/event.go | 57 ++--- server/internal/repository/event_test.go | 13 +- .../internal/service/connection_writeback.go | 17 +- server/internal/service/event.go | 20 +- server/internal/service/event_series.go | 42 +++- server/internal/service/event_writeback.go | 14 +- .../internal/service/event_writeback_test.go | 207 ++++++++++++++++++ 7 files changed, 307 insertions(+), 63 deletions(-) diff --git a/server/internal/repository/event.go b/server/internal/repository/event.go index 43e0714..9062ef3 100644 --- a/server/internal/repository/event.go +++ b/server/internal/repository/event.go @@ -197,19 +197,17 @@ type EventFields struct { // Refresh reconciles by it rather than ever updating it in place. ExternalUID *string // ProviderEtag/RSVPStatus/ConferenceURL/GuestCount/ProviderColor mirror - // Event's own fields (#287, #289, ADR-0052, ADR-0075) — unlike - // ExternalUID, these are written on every Update too, since a Linked - // Calendar's Full Refresh must move them forward when the Provider's own - // copy changes. Update writes whatever this EventFields carries - // unconditionally, with no merge against the existing row — every caller - // today (SeriesWrite/OverrideWrite.fields()) either owns these fields (a - // Refresh) or leaves them at zero value (every other write path), and - // requireWritableCalendar's read-only clamp keeps the latter off a - // Linked Calendar's Events entirely. Write-back (ADR-0075) will change - // that: whichever write path it adds must read the existing row's - // values forward into its own EventFields first, or a plain field edit - // will silently null these out. ProviderColor in particular is never - // pushed back to the Provider — colour is one-way inbound (ADR-0075). + // Event's own fields (#287, #289, ADR-0052, ADR-0075) — set on Create, + // like ExternalUID, but unlike ExternalUID also ignored by Update + // (#298): Update's SQL simply doesn't reference these five columns, so + // no EventFields passed to it — however it carries these fields — can + // touch them. They move only through ApplyProviderOwnedFields, + // UpdateProviderEtag, and AdoptProviderIdentity, the three narrow + // writers built for exactly this, which makes "only Refresh and + // Write-back's own responses move these" true by construction rather + // than a carry-forward each caller must remember. ProviderColor in + // particular is never pushed back to the Provider — colour is one-way + // inbound (ADR-0075). ProviderEtag *string RSVPStatus *string ConferenceURL *string @@ -377,14 +375,18 @@ func (r *EventRepository) ListAllWithAnyReminder(ctx context.Context) ([]Event, } // Update rewrites id's columns from f. f.ParentID and f.RecurrenceID are -// ignored — see EventFields. sequence is the row's new iTIP SEQUENCE +// ignored — see EventFields. So, also per EventFields' own doc comment, are +// f.ProviderEtag/RSVPStatus/ConferenceURL/GuestCount/ProviderColor (#298): +// this SQL simply has no column for them, so no caller of Update — however +// it populates those fields on f — can zero a Linked Calendar's Provider +// state with a plain edit. sequence is the row's new iTIP SEQUENCE // (ADR-0059, #201) — the caller decides its value (bumped on a material // change, left as-is otherwise), since only EventService knows whether f // differs from the row being replaced. func (r *EventRepository) Update(ctx context.Context, id string, f EventFields, changeSeq, sequence int64) (Event, error) { res, err := r.db.ExecContext(ctx, - `UPDATE events SET calendar_id = ?, title = ?, "start" = ?, "end" = ?, all_day = ?, rrule = ?, tzid = ?, description = ?, location = ?, url = ?, color = ?, change_seq = ?, sequence = ?, provider_etag = ?, rsvp_status = ?, conference_url = ?, guest_count = ?, provider_color = ? WHERE id = ?`, - f.CalendarID, f.Title, f.Start.UTC(), f.End.UTC(), f.AllDay, f.Rrule, f.Tzid, f.Description, f.Location, f.URL, f.Color, changeSeq, sequence, f.ProviderEtag, f.RSVPStatus, f.ConferenceURL, f.GuestCount, f.ProviderColor, id, + `UPDATE events SET calendar_id = ?, title = ?, "start" = ?, "end" = ?, all_day = ?, rrule = ?, tzid = ?, description = ?, location = ?, url = ?, color = ?, change_seq = ?, sequence = ? WHERE id = ?`, + f.CalendarID, f.Title, f.Start.UTC(), f.End.UTC(), f.AllDay, f.Rrule, f.Tzid, f.Description, f.Location, f.URL, f.Color, changeSeq, sequence, id, ) if err != nil { return Event{}, fmt.Errorf("update event: %w", err) @@ -434,16 +436,21 @@ func (r *EventRepository) AdoptProviderIdentity(ctx context.Context, id, externa return requireAffected(res) } -// ApplyProviderOwnedFields moves rsvpStatus/conferenceURL/guestCount -// forward on id's row alone (#291, ADR-0075) — SendWriteBack's own -// conflict-retry loop, reached after a 412 forces a refetch of the -// Provider's current copy anyway. Deliberately narrower than Update: no -// other column moves, and change_seq never bumps — these three are the -// Provider's own read-only state, not a second edit to the Event. -func (r *EventRepository) ApplyProviderOwnedFields(ctx context.Context, id string, rsvpStatus, conferenceURL *string, guestCount int) error { +// ApplyProviderOwnedFields moves rsvpStatus/conferenceURL/guestCount/ +// providerColor forward on id's row alone (#291, #298, ADR-0075) — one of +// upsertSeries' two narrow writers for a Refresh-reconciled update (the +// other being UpdateProviderEtag), and also SendWriteBack's own +// conflict-retry loop (reconcileProviderOwnedFields), reached after a 412 +// forces a refetch of the Provider's current copy anyway; that caller +// passes the row's own already-stored providerColor back unchanged, since +// colour there is governed by the until-touched rule the next ordinary +// Refresh re-applies, not by a conflict retry. Deliberately narrower than +// Update: no other column moves, and change_seq never bumps — these four +// are the Provider's own read-only state, not a second edit to the Event. +func (r *EventRepository) ApplyProviderOwnedFields(ctx context.Context, id string, rsvpStatus, conferenceURL *string, guestCount int, providerColor *string) error { res, err := r.db.ExecContext(ctx, - `UPDATE events SET rsvp_status = ?, conference_url = ?, guest_count = ? WHERE id = ?`, - rsvpStatus, conferenceURL, guestCount, id, + `UPDATE events SET rsvp_status = ?, conference_url = ?, guest_count = ?, provider_color = ? WHERE id = ?`, + rsvpStatus, conferenceURL, guestCount, providerColor, id, ) if err != nil { return fmt.Errorf("apply provider-owned fields: %w", err) diff --git a/server/internal/repository/event_test.go b/server/internal/repository/event_test.go index 0504658..041e366 100644 --- a/server/internal/repository/event_test.go +++ b/server/internal/repository/event_test.go @@ -814,9 +814,10 @@ func TestEventRepository_MarkWriteBackFailed_NotFound(t *testing.T) { } } -// TestEventRepository_ApplyProviderOwnedFields covers #291's conflict-retry -// helper: RSVPStatus/ConferenceURL/GuestCount move, and nothing else does — -// in particular the title an ordinary Update would otherwise carry. +// TestEventRepository_ApplyProviderOwnedFields covers #291/#298's +// conflict-retry and Refresh-reconcile helper: RSVPStatus/ConferenceURL/ +// GuestCount/ProviderColor move, and nothing else does — in particular the +// title an ordinary Update would otherwise carry. func TestEventRepository_ApplyProviderOwnedFields(t *testing.T) { repo, userID, calendarID, _ := newTestEventRepository(t) ctx := context.Background() @@ -824,7 +825,8 @@ func TestEventRepository_ApplyProviderOwnedFields(t *testing.T) { rsvp := "declined" conferenceURL := "https://meet.example.com/xyz" - if err := repo.ApplyProviderOwnedFields(ctx, "evt-1", &rsvp, &conferenceURL, 5); err != nil { + providerColor := "#123456" + if err := repo.ApplyProviderOwnedFields(ctx, "evt-1", &rsvp, &conferenceURL, 5, &providerColor); err != nil { t.Fatalf("apply provider-owned fields: %v", err) } @@ -841,6 +843,9 @@ func TestEventRepository_ApplyProviderOwnedFields(t *testing.T) { if got.GuestCount != 5 { t.Fatalf("expected GuestCount applied, got %d", got.GuestCount) } + if got.ProviderColor == nil || *got.ProviderColor != "#123456" { + t.Fatalf("expected ProviderColor applied, got %v", got.ProviderColor) + } if got.Title != "evt-1" { t.Fatalf("expected the title left untouched, got %q", got.Title) } diff --git a/server/internal/service/connection_writeback.go b/server/internal/service/connection_writeback.go index d79ede3..e2e07c7 100644 --- a/server/internal/service/connection_writeback.go +++ b/server/internal/service/connection_writeback.go @@ -375,7 +375,7 @@ func (s *ConnectionService) sendWriteBackUpsert(ctx context.Context, msg reposit } mapped := toGoogleEvent(fresh) etag = googleEtag(mapped.ETag) - if err := s.reconcileProviderOwnedFields(ctx, event.ID, mapped); err != nil { + if err := s.reconcileProviderOwnedFields(ctx, event.ID, mapped, event.ProviderColor); err != nil { return err } } @@ -516,7 +516,7 @@ func (s *ConnectionService) sendWriteBackInstance(ctx context.Context, msg repos } mapped := toGoogleEvent(fresh) etag = googleEtag(mapped.ETag) - if err := s.reconcileProviderOwnedFields(ctx, override.ID, mapped); err != nil { + if err := s.reconcileProviderOwnedFields(ctx, override.ID, mapped, override.ProviderColor); err != nil { return err } } @@ -627,14 +627,15 @@ func (s *ConnectionService) sendWriteBackInstanceCancel(ctx context.Context, msg // pending edit about to be re-pushed with the fresh etag, or (colour) // governed by the until-touched rule the next ordinary Refresh already // re-applies within minutes, so this narrower, more time-critical path has -// no need to duplicate it. RSVPStatus, ConferenceURL and GuestCount are the -// Provider's alone, never this app's to have an opinion about, so they -// always move forward unconditionally, exactly as an ordinary Refresh -// already applies them. -func (s *ConnectionService) reconcileProviderOwnedFields(ctx context.Context, id string, fresh googleEvent) error { +// no need to duplicate it: providerColor is the caller's own already-stored +// value, passed straight back rather than derived from fresh. RSVPStatus, +// ConferenceURL and GuestCount are the Provider's alone, never this app's to +// have an opinion about, so they always move forward unconditionally, +// exactly as an ordinary Refresh already applies them. +func (s *ConnectionService) reconcileProviderOwnedFields(ctx context.Context, id string, fresh googleEvent, providerColor *string) error { rsvp, guestCount := googleGuestInfo(fresh.Attendees) conferenceURL := googleConferenceURL(fresh.ConferenceData) - return s.events.ApplyProviderOwnedFields(ctx, id, rsvp, conferenceURL, guestCount) + return s.events.ApplyProviderOwnedFields(ctx, id, rsvp, conferenceURL, guestCount, providerColor) } // markWriteBackPermanentlyFailed records a Write-back push that will never diff --git a/server/internal/service/event.go b/server/internal/service/event.go index 0bd7e45..4062aab 100644 --- a/server/internal/service/event.go +++ b/server/internal/service/event.go @@ -908,22 +908,12 @@ func (s *EventService) Update(ctx context.Context, userID int64, id string, writ // when it fires. effects := classifyUpdate(existing, write) - // ProviderEtag/RSVPStatus/ConferenceURL/GuestCount/ProviderColor are a - // Linked Calendar's own Refresh-owned columns (#287, #289, ADR-0052, - // ADR-0075) — write.fields() carries none of them (every ordinary write - // path leaves them at zero value), and EventRepository.Update writes - // whatever EventFields it's given unconditionally, with no merge against - // the stored row. Without this, editing so much as the title of an Event - // on a Linked Calendar would silently null out its Provider RSVP, - // conference link, guest count and colour shadow. Carrying existing's - // values forward is a no-op for every Event this app itself owns, where - // both sides are already zero. + // write.fields() leaves ProviderEtag/RSVPStatus/ConferenceURL/GuestCount/ + // ProviderColor at zero value, and repository.EventRepository.Update's + // SQL has no column for any of the five (#298) — a Linked Calendar's + // Refresh-owned state survives a plain field edit like this title-only + // one by construction, not by carrying existing's values forward here. fields := write.fields() - fields.ProviderEtag = existing.ProviderEtag - fields.RSVPStatus = existing.RSVPStatus - fields.ConferenceURL = existing.ConferenceURL - fields.GuestCount = existing.GuestCount - fields.ProviderColor = existing.ProviderColor // Write-back (#290, ADR-0075): an Event on a writable Linked Calendar gets // its push queued in the very same transaction as the local write it diff --git a/server/internal/service/event_series.go b/server/internal/service/event_series.go index 14e7f05..e9af629 100644 --- a/server/internal/service/event_series.go +++ b/server/internal/service/event_series.go @@ -102,7 +102,7 @@ func (s *EventService) writeSeries(ctx context.Context, userID int64, calendarID } for _, w := range writes { - if err := s.upsertSeries(ctx, repos, calendarID, uuid.NewString(), userID, reminderUserID, seq, w, false); err != nil { + if err := s.upsertSeries(ctx, repos, calendarID, uuid.NewString(), userID, reminderUserID, seq, w, false, false); err != nil { return err } } @@ -217,7 +217,7 @@ func (s *EventService) createSubscribedSeries(ctx context.Context, repos txRepos if err != nil { return err } - return s.upsertSeries(ctx, repos, calendarID, uuid.NewString(), userID, ownerID, seq, write, false) + return s.upsertSeries(ctx, repos, calendarID, uuid.NewString(), userID, ownerID, seq, write, false, true) } // updateSubscribedSeries updates masterID's row and its Overrides in place @@ -235,7 +235,7 @@ func (s *EventService) updateSubscribedSeries(ctx context.Context, repos txRepos if err != nil { return err } - return s.upsertSeries(ctx, repos, calendarID, masterID, userID, ownerID, seq, write, false) + return s.upsertSeries(ctx, repos, calendarID, masterID, userID, ownerID, seq, write, false, true) } // tombstoneSubscribedSeries deletes masterID outright (cascading to its @@ -550,7 +550,23 @@ func validateSeriesWrite(write *SeriesWrite) error { // User's Calendar default must stay free to keep applying wherever the // import or feed didn't itself specify one — writeSeries, // createSubscribedSeries, and updateSubscribedSeries all pass false. -func (s *EventService) upsertSeries(ctx context.Context, repos txRepos, calendarID, masterID string, userID, reminderUserID, seq int64, write SeriesWrite, markExplicit bool) error { +// +// providerOwned decides whether an existing Master/Override's update also +// moves ProviderEtag/RSVPStatus/ConferenceURL/GuestCount/ProviderColor +// forward, via ApplyProviderOwnedFields and UpdateProviderEtag (#298, +// ADR-0075) — repository.EventRepository.Update's SQL no longer has a +// column for any of the five, so skipping this for a caller that doesn't +// own fresh Provider data (writeSeries, PutSeries) is what makes their zero +// values structurally unable to blank a Linked Calendar's stored ones, +// rather than relying on write.fields() happening to leave them zero today. +// createSubscribedSeries and updateSubscribedSeries pass true: their write +// comes from ReconcileSeries' own diff against a fresh Provider fetch (or, +// for a plain ICS Subscription, from a source that never sets these fields +// at all, so moving its zero values forward is a no-op). Never needed on a +// Create branch — repository.EventRepository.Create's SQL still writes +// these columns directly from f, correctly, since a freshly-minted row has +// no prior value to protect. +func (s *EventService) upsertSeries(ctx context.Context, repos txRepos, calendarID, masterID string, userID, reminderUserID, seq int64, write SeriesWrite, markExplicit, providerOwned bool) error { // masterID missing is create-vs-update's only signal, deliberately — // including for updateSubscribedSeries's caller (ReconcileSubscribedSeries), // which believes masterID already exists: recreating a row unexpectedly @@ -574,6 +590,14 @@ func (s *EventService) upsertSeries(ctx context.Context, repos txRepos, calendar if _, err := repos.events.Update(ctx, masterID, master, seq, existingMaster.Sequence); err != nil { return fmt.Errorf("update master: %w", err) } + if providerOwned { + if err := repos.events.ApplyProviderOwnedFields(ctx, masterID, master.RSVPStatus, master.ConferenceURL, master.GuestCount, master.ProviderColor); err != nil { + return fmt.Errorf("apply master provider-owned fields: %w", err) + } + if err := repos.events.UpdateProviderEtag(ctx, masterID, master.ProviderEtag); err != nil { + return fmt.Errorf("update master provider etag: %w", err) + } + } } else { if _, err := repos.events.Create(ctx, masterID, &userID, master, seq); err != nil { return fmt.Errorf("create master: %w", err) @@ -628,6 +652,14 @@ func (s *EventService) upsertSeries(ctx context.Context, repos txRepos, calendar if _, err := repos.events.Update(ctx, existing.ID, override, seq, existing.Sequence); err != nil { return fmt.Errorf("update override: %w", err) } + if providerOwned { + if err := repos.events.ApplyProviderOwnedFields(ctx, existing.ID, override.RSVPStatus, override.ConferenceURL, override.GuestCount, override.ProviderColor); err != nil { + return fmt.Errorf("apply override provider-owned fields: %w", err) + } + if err := repos.events.UpdateProviderEtag(ctx, existing.ID, override.ProviderEtag); err != nil { + return fmt.Errorf("update override provider etag: %w", err) + } + } if err := repos.reminders.ReplaceByEventID(ctx, reminderUserID, existing.ID, o.Reminders); err != nil { return fmt.Errorf("persist override reminders: %w", err) } @@ -718,7 +750,7 @@ func (s *EventService) PutSeries(ctx context.Context, userID int64, calendarID, // PUTting User's own Reminders, not the Calendar's Owner's // (ADR-0064) — hence userID for both userID and reminderUserID, and // markExplicit true (see upsertSeries' doc comment). - return s.upsertSeries(ctx, repos, calendarID, masterID, userID, userID, seq, write, true) + return s.upsertSeries(ctx, repos, calendarID, masterID, userID, userID, seq, write, true, false) }) if err != nil { return repository.Event{}, nil, fmt.Errorf("put series: %w", err) diff --git a/server/internal/service/event_writeback.go b/server/internal/service/event_writeback.go index 8c78eff..f584e40 100644 --- a/server/internal/service/event_writeback.go +++ b/server/internal/service/event_writeback.go @@ -97,12 +97,14 @@ func (s *EventService) MarkWriteBackFailed(ctx context.Context, id, reason strin } // ApplyProviderOwnedFields moves id's Provider-owned RSVPStatus/ -// ConferenceURL/GuestCount forward from a fresh Provider fetch (#291, -// ADR-0075) — ConnectionService's own conflict-retry loop -// (reconcileProviderOwnedFields) is the only caller, reached after a 412 -// forces a refetch anyway. -func (s *EventService) ApplyProviderOwnedFields(ctx context.Context, id string, rsvpStatus, conferenceURL *string, guestCount int) error { - return s.events.ApplyProviderOwnedFields(ctx, id, rsvpStatus, conferenceURL, guestCount) +// ConferenceURL/GuestCount/ProviderColor forward (#291, #298, ADR-0075). +// Called both by upsertSeries' Refresh-reconciled update path, with the +// Refresh's own fresh values, and by ConnectionService's conflict-retry loop +// (reconcileProviderOwnedFields) after a 412 forces a refetch anyway, which +// passes providerColor back unchanged since colour there is the next +// ordinary Refresh's job, not a conflict retry's. +func (s *EventService) ApplyProviderOwnedFields(ctx context.Context, id string, rsvpStatus, conferenceURL *string, guestCount int, providerColor *string) error { + return s.events.ApplyProviderOwnedFields(ctx, id, rsvpStatus, conferenceURL, guestCount, providerColor) } // ClearProviderIdentity strips the Provider from every Event of calendarID diff --git a/server/internal/service/event_writeback_test.go b/server/internal/service/event_writeback_test.go index 351abf8..f7615a3 100644 --- a/server/internal/service/event_writeback_test.go +++ b/server/internal/service/event_writeback_test.go @@ -357,6 +357,213 @@ func TestEventService_Update_CarriesForwardProviderOwnedFields(t *testing.T) { } } +// TestEventService_UpsertSeries_NeverWritesProviderOwnedFieldsWhenNotProviderOwned +// is #298's structural guarantee, exercised directly at upsertSeries' own +// seam rather than through EventService.Update: a write shaped like a +// CalDAV PUT or an ICS import — carrying zero Provider-owned fields, exactly +// what SeriesWrite.fields() produces for every caller but a Refresh +// reconcile — must never blank a Linked Calendar Master's stored Provider +// state. repository.EventRepository.Update has no column for any of the +// five, and upsertSeries only calls the narrow writers that do +// (ApplyProviderOwnedFields, UpdateProviderEtag) when providerOwned is +// true, so this survives by construction rather than by upsertSeries +// remembering to carry the old values forward. +func TestEventService_UpsertSeries_NeverWritesProviderOwnedFieldsWhenNotProviderOwned(t *testing.T) { + g := newTestGraph(t) + userID, calendarID := newTestLinkedCalendar(t, g, repository.SourceModeWritable) + event := seedLinkedMaster(t, g, userID, calendarID, "evt-linked-upsert") + ctx := context.Background() + + err := g.Events.withTx(ctx, func(repos txRepos) error { + seq, err := repos.sync.NextChangeSeq(ctx) + if err != nil { + return err + } + write := SeriesWrite{Title: "Renamed only", Start: event.Start, End: event.End} + return g.Events.upsertSeries(ctx, repos, calendarID, event.ID, userID, userID, seq, write, false, false) + }) + if err != nil { + t.Fatalf("upsert series: %v", err) + } + + got, err := g.EventRepo.GetByID(ctx, event.ID) + if err != nil { + t.Fatalf("get event: %v", err) + } + if got.Title != "Renamed only" { + t.Fatalf("expected the title to update, got %q", got.Title) + } + if got.RSVPStatus == nil || *got.RSVPStatus != "accepted" { + t.Fatalf("expected RSVPStatus to survive, got %v", got.RSVPStatus) + } + if got.ConferenceURL == nil || *got.ConferenceURL != "https://meet.example.com/abc" { + t.Fatalf("expected ConferenceURL to survive, got %v", got.ConferenceURL) + } + if got.GuestCount != 3 { + t.Fatalf("expected GuestCount to survive, got %d", got.GuestCount) + } + if got.ProviderEtag == nil || *got.ProviderEtag != "etag-1" { + t.Fatalf("expected ProviderEtag to survive, got %v", got.ProviderEtag) + } +} + +// TestEventService_ReconcileSubscribedSeries_MovesProviderOwnedFieldsForwardOnUpdate +// is Full/Delta Refresh's own forward-movement path (#298, ADR-0075): +// ReconcileSubscribedSeries' update branch (providerOwned=true in +// upsertSeries) must still move a fresh Provider fetch's RSVPStatus/ +// ConferenceURL/GuestCount/ProviderEtag/ProviderColor onto an existing +// Master, now that repository.EventRepository.Update itself no longer +// touches those columns — this is the acceptance criterion that the +// existing Write-back and Refresh suites must stay green against. +func TestEventService_ReconcileSubscribedSeries_MovesProviderOwnedFieldsForwardOnUpdate(t *testing.T) { + g := newTestGraph(t) + userID, calendarID := newTestLinkedCalendar(t, g, repository.SourceModeWritable) + event := seedLinkedMaster(t, g, userID, calendarID, "evt-linked-refresh") + ctx := context.Background() + + newRSVP := "declined" + newConference := "https://meet.example.com/fresh" + newEtag := "etag-2" + newColor := "#abcdef" + result := ReconcileResult{ + Upserts: []SeriesUpsert{ + { + MasterID: event.ID, + Write: SeriesWrite{ + Title: event.Title, Start: event.Start, End: event.End, + ExternalUID: *event.ExternalUID, + ProviderEtag: &newEtag, + RSVPStatus: &newRSVP, + ConferenceURL: &newConference, + GuestCount: 7, + ProviderColor: &newColor, + }, + }, + }, + } + + summary, err := g.Events.ReconcileSubscribedSeries(ctx, userID, calendarID, result) + if err != nil { + t.Fatalf("reconcile: %v", err) + } + if summary.Updated != 1 { + t.Fatalf("expected 1 update, got %+v", summary) + } + + got, err := g.EventRepo.GetByID(ctx, event.ID) + if err != nil { + t.Fatalf("get event: %v", err) + } + if got.RSVPStatus == nil || *got.RSVPStatus != newRSVP { + t.Fatalf("expected RSVPStatus to move forward, got %v", got.RSVPStatus) + } + if got.ConferenceURL == nil || *got.ConferenceURL != newConference { + t.Fatalf("expected ConferenceURL to move forward, got %v", got.ConferenceURL) + } + if got.GuestCount != 7 { + t.Fatalf("expected GuestCount to move forward, got %d", got.GuestCount) + } + if got.ProviderEtag == nil || *got.ProviderEtag != newEtag { + t.Fatalf("expected ProviderEtag to move forward, got %v", got.ProviderEtag) + } + if got.ProviderColor == nil || *got.ProviderColor != newColor { + t.Fatalf("expected ProviderColor to move forward, got %v", got.ProviderColor) + } +} + +// TestEventService_UpsertSeries_NeverWritesProviderOwnedFieldsOnOverrideWhenNotProviderOwned +// is the Override branch's own copy of the Master test above (#298): an +// Override's Provider-owned fields live at a different row id than its +// Master's, reached by upsertSeries' separate existing-Override-found +// branch, so it needs its own construction check rather than trusting the +// Master branch's coverage to stand in for it. +func TestEventService_UpsertSeries_NeverWritesProviderOwnedFieldsOnOverrideWhenNotProviderOwned(t *testing.T) { + g := newTestGraph(t) + userID, calendarID := newTestLinkedCalendar(t, g, repository.SourceModeWritable) + ctx := context.Background() + + masterStart := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC) + masterID := "evt-linked-master-for-override" + externalUID := "google-event-series-1" + seq, err := g.SyncRepo.NextChangeSeq(ctx) + if err != nil { + t.Fatalf("next change seq: %v", err) + } + if _, err := g.EventRepo.Create(ctx, masterID, &userID, repository.EventFields{ + CalendarID: calendarID, + Title: "Standup", + Start: masterStart, + End: masterStart.Add(30 * time.Minute), + Rrule: "FREQ=DAILY", + ExternalUID: &externalUID, + }, seq); err != nil { + t.Fatalf("seed master: %v", err) + } + + recurrenceID := masterStart.AddDate(0, 0, 1) + overrideID := "evt-linked-override" + overrideStart := recurrenceID.Add(time.Hour) + rsvp := "accepted" + conferenceURL := "https://meet.example.com/override" + etag := "override-etag-1" + seq, err = g.SyncRepo.NextChangeSeq(ctx) + if err != nil { + t.Fatalf("next change seq: %v", err) + } + if _, err := g.EventRepo.Create(ctx, overrideID, &userID, repository.EventFields{ + CalendarID: calendarID, + Title: "Standup (moved)", + Start: overrideStart, + End: overrideStart.Add(30 * time.Minute), + ParentID: &masterID, + RecurrenceID: &recurrenceID, + ExternalUID: &externalUID, + ProviderEtag: &etag, + RSVPStatus: &rsvp, + ConferenceURL: &conferenceURL, + GuestCount: 2, + }, seq); err != nil { + t.Fatalf("seed override: %v", err) + } + + err = g.Events.withTx(ctx, func(repos txRepos) error { + seq, err := repos.sync.NextChangeSeq(ctx) + if err != nil { + return err + } + write := SeriesWrite{ + Title: "Standup", Start: masterStart, End: masterStart.Add(30 * time.Minute), Rrule: "FREQ=DAILY", + Overrides: []OverrideWrite{ + {RecurrenceID: recurrenceID, Title: "Standup (renamed only)", Start: overrideStart, End: overrideStart.Add(30 * time.Minute)}, + }, + } + return g.Events.upsertSeries(ctx, repos, calendarID, masterID, userID, userID, seq, write, false, false) + }) + if err != nil { + t.Fatalf("upsert series: %v", err) + } + + got, err := g.EventRepo.GetByID(ctx, overrideID) + if err != nil { + t.Fatalf("get override: %v", err) + } + if got.Title != "Standup (renamed only)" { + t.Fatalf("expected the title to update, got %q", got.Title) + } + if got.RSVPStatus == nil || *got.RSVPStatus != "accepted" { + t.Fatalf("expected override RSVPStatus to survive, got %v", got.RSVPStatus) + } + if got.ConferenceURL == nil || *got.ConferenceURL != "https://meet.example.com/override" { + t.Fatalf("expected override ConferenceURL to survive, got %v", got.ConferenceURL) + } + if got.GuestCount != 2 { + t.Fatalf("expected override GuestCount to survive, got %d", got.GuestCount) + } + if got.ProviderEtag == nil || *got.ProviderEtag != "override-etag-1" { + t.Fatalf("expected override ProviderEtag to survive, got %v", got.ProviderEtag) + } +} + // TestEventService_PutSeries_RefusesConnectionCalendarMatchingCalDAVsPosture // covers the CalDAV write seam directly (#290): a CalDAV PUT onto a Linked // Calendar is refused independent of Exposure (ADR-0080) or the Source's From cb4ac34fb9a9af39f917c3947986074fb0a1984f Mon Sep 17 00:00:00 2001 From: XiovV Date: Thu, 10 Sep 2026 17:07:29 +0200 Subject: [PATCH 04/33] Writable CalDAV on a Linked Calendar: a PUT compiles to Write-back pushes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PutSeries' blanket refusal of Connection Sources becomes conditional: it proceeds where the requesting principal has Exposure on (ADR-0080) and a live, writable Connection, diffing the incoming series against stored state to derive Write-back pushes rather than blindly re-pushing an unchanged Master on every save. Six deltas: Master fields changed (PATCH), Override added/changed (INSTANCE), Exdate added (CANCEL_INSTANCE), series newly created (POST) all push; Override removed or Exdate removed are refused before any local write, atomically — except an Override removed alongside a matching new Exdate, the standard shape for "delete this already-modified Occurrence", which cancels rather than refusing. DeleteCalendarObject needed no new write logic, only the same exposure-aware visibility check. A permanently failed push now also raises a Notification, coalesced on the Calendar (not the Event) via a partial unique index, so a dead grant failing dozens of queued pushes still surfaces exactly one. Delivers ADR-0081. Fixes #299 --- CONTEXT.md | 2 +- ...-caldav-home-set-membership-is-per-user.md | 2 +- ...d-calendar-a-put-compiles-to-write-back.md | 64 +++ server/internal/caldavserver/object.go | 41 +- .../caldavserver/writeback_put_test.go | 364 ++++++++++++++++++ .../00008_writeback_failure_notification.sql | 76 ++++ server/internal/handlers/notification.go | 13 +- server/internal/handlers/notification_test.go | 2 +- server/internal/reminder/dispatch_test.go | 4 +- server/internal/repository/notification.go | 74 +++- .../internal/repository/notification_test.go | 88 ++++- server/internal/service/attendee_test.go | 2 +- server/internal/service/connection.go | 10 +- .../service/connection_picker_test.go | 2 +- server/internal/service/connection_test.go | 2 +- .../internal/service/connection_writeback.go | 42 +- .../service/connection_writeback_test.go | 27 +- server/internal/service/event.go | 9 + .../service/event_create_attendee_test.go | 2 +- .../event_put_series_writeback_test.go | 360 +++++++++++++++++ server/internal/service/event_series.go | 304 ++++++++++++++- .../internal/service/event_writeback_test.go | 28 +- server/internal/service/graph.go | 2 +- src/components/layout/NotificationBell.tsx | 15 +- src/lib/notification.ts | 26 +- src/lib/notificationsApi.ts | 14 +- src/lib/notificationsStore.ts | 12 +- 27 files changed, 1475 insertions(+), 112 deletions(-) create mode 100644 docs/adr/0081-writable-caldav-on-a-linked-calendar-a-put-compiles-to-write-back.md create mode 100644 server/internal/caldavserver/writeback_put_test.go create mode 100644 server/internal/db/migrations/00008_writeback_failure_notification.sql create mode 100644 server/internal/service/event_put_series_writeback_test.go diff --git a/CONTEXT.md b/CONTEXT.md index 3351019..f164763 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -98,7 +98,7 @@ The delivery method of a Reminder — **Notification** (shown in-app) or **Email _Avoid_: type, kind, action (in prose) **Notification**: -The persistent in-app record of something that concerns one User. Lives in the Notification feed and, if a tab is open, is mirrored as a browser notification. Exactly two things produce one: a Reminder on the Notification Channel firing (where the Reminder is the rule and the Notification the delivered instance), and the User being made an Attendee of an Event. Deliberately nothing else — being removed as an Attendee, an Event moving, and another Attendee's Response all produce none, the last because the Attendee list already shows every Response and a fourteen-person invite would otherwise mean fourteen entries. The Organizer never gets one for their own Event, since an Organizer is never an Attendee. Distinct from a toast, which is a transient client-only UI message (e.g. an error). See ADR-0021, ADR-0061. +The persistent in-app record of something that concerns one User. Lives in the Notification feed and, if a tab is open, is mirrored as a browser notification. Three things produce one: a Reminder on the Notification Channel firing (where the Reminder is the rule and the Notification the delivered instance), the User being made an Attendee of an Event, and a Write-back push permanently failing on a writable Linked Calendar — the last coalesced on the Calendar rather than the Event, since a dead Connection fails every one of its queued pushes at once and would otherwise raise dozens. Deliberately nothing else beyond these three — being removed as an Attendee, an Event moving, and another Attendee's Response all produce none, the last because the Attendee list already shows every Response and a fourteen-person invite would otherwise mean fourteen entries. The Organizer never gets one for their own Event, since an Organizer is never an Attendee. Distinct from a toast, which is a transient client-only UI message (e.g. an error). See ADR-0021, ADR-0061, ADR-0081. _Avoid_: alert, toast (for this), push **Day cell**: diff --git a/docs/adr/0080-exposure-caldav-home-set-membership-is-per-user.md b/docs/adr/0080-exposure-caldav-home-set-membership-is-per-user.md index 586ef09..5b54bb4 100644 --- a/docs/adr/0080-exposure-caldav-home-set-membership-is-per-user.md +++ b/docs/adr/0080-exposure-caldav-home-set-membership-is-per-user.md @@ -2,7 +2,7 @@ Status: accepted — supersedes ADR-0074, reintroducing ADR-0054's viewer-dependent design -Whether a Calendar appears in a User's own CalDAV home-set is now that User's own choice, resolved per `(calendar_id, user_id)` from `calendar_exposures` — a table reusing `calendar_user_colors`' shape verbatim (ADR-0038): no indirection, cascading on both Calendar and User. Absent a row, the default is exposed, except a Linked Calendar's own Owner, who defaults to unexposed. The home-set filter and the direct-path lookup both consult it for **every** Calendar, not only Connection-sourced ones, even though only a Linked Calendar gets a UI toggle today (Not in scope, below). Read-only for now — Exposure surviving a device's own write is #299. +Whether a Calendar appears in a User's own CalDAV home-set is now that User's own choice, resolved per `(calendar_id, user_id)` from `calendar_exposures` — a table reusing `calendar_user_colors`' shape verbatim (ADR-0038): no indirection, cascading on both Calendar and User. Absent a row, the default is exposed, except a Linked Calendar's own Owner, who defaults to unexposed. The home-set filter and the direct-path lookup both consult it for **every** Calendar, not only Connection-sourced ones, even though only a Linked Calendar gets a UI toggle today (Not in scope, below). Read-only when this landed — a device's own write surviving Exposure is ADR-0081 (#299). ## Why the cost ADR-0074 refused to pay is already paid diff --git a/docs/adr/0081-writable-caldav-on-a-linked-calendar-a-put-compiles-to-write-back.md b/docs/adr/0081-writable-caldav-on-a-linked-calendar-a-put-compiles-to-write-back.md new file mode 100644 index 0000000..3807e02 --- /dev/null +++ b/docs/adr/0081-writable-caldav-on-a-linked-calendar-a-put-compiles-to-write-back.md @@ -0,0 +1,64 @@ +# Writable CalDAV on a Linked Calendar: a PUT compiles to Write-back pushes + +Status: accepted — extends ADR-0075, ADR-0077, ADR-0078 (Write-back) and ADR-0080 (Exposure); the two blockers it depends on (#297, #298) have shipped + +The Owner of an exposed, writable Linked Calendar can now edit it from a real CalDAV client — a phone, a desktop calendar app — with every change carried to Google by the Write-back machinery ADR-0075/0077/0078 already built for the web app. `PutSeries`, CalDAV's own write path, used to refuse a Connection Source outright, independent of Exposure or Mode (ADR-0074's original posture, echoed unchanged when #299 was parked). That refusal is now conditional: it proceeds where Exposure is on and Access can write, and still 404s where it isn't — matching the direct-path posture ADR-0080 already established, rather than confirming the Calendar exists and refusing the write on it. + +## The core problem, and why it needs a diff + +A CalDAV `PUT` replaces a **whole Calendar object** — Master, every Override, every Exdate — every time, even when a phone's own scoped edit ("this event", "this and following") touched only one Occurrence. The web app instead compiles a scoped edit into ADR-0016's Series operations and queues one push per operation. CalDAV has no such vocabulary: the object arriving is the *result* the client wants, not the steps that produced it. + +So `PutSeries` diffs the incoming `SeriesWrite` against the series' currently stored state, and derives Write-back pushes from what actually changed — never from what a naive "push on every PUT" would send. Without the diff, editing one Occurrence from a phone would also re-push an unchanged Master on every save, because the resent object still carries the Master's own fields even though they didn't change. + +## The six deltas + +Four map onto Write-back methods that already exist, unchanged from ADR-0075/0077/0078: + +| Delta | Push | +| --- | --- | +| Master's own pushed fields changed | existing master `PATCH` (or `POST`, if it has never reached the Provider) | +| Override added or changed | existing `INSTANCE` | +| Exdate added | existing `CANCEL_INSTANCE` | +| Series newly created | existing `POST` | + +Two are refused outright, before `upsertSeries` writes anything: + +| Delta | Disposition | +| --- | --- | +| Override removed, and its Occurrence isn't also newly excepted | `403`, refused | +| Exdate removed | `403`, refused | + +**The two refusals happen before the local write, not after.** A partially-applied `PUT` that pushed some deltas and dropped others is the exact failure this design exists to prevent — refusing up-front (`buildPutSeriesWriteBack`, called before `upsertSeries` ever runs) leaves the client holding its own copy and the store untouched. Both are expressible at Google (patch an instance back to the master's values; patch a cancelled instance back to confirmed) and both are deliberately deferred: they are the two rarest things a phone does, and adding them later does not change the diff's shape. + +**Removing an Override is not always a revert.** The standard CalDAV shape for "delete this already-modified Occurrence" is exactly what a naive reading of "Override removed" would refuse: the Override VEVENT drops out of the object, and an `EXDATE` line appears for its `RECURRENCE-ID` in the same `PUT`. That combination is not a revert-to-rule request — it's an ordinary cancellation, and the diff recognizes it as one: an Override missing from the incoming write is refused only when its `RECURRENCE-ID` isn't *also* a newly-added Exdate. When it is, the Exdate's own `CANCEL_INSTANCE` push already covers it, and the Override row is simply deleted locally (`upsertSeries`' existing cleanup, unchanged). + +## Exposure gates the write, not just the read + +`PutSeries` resolves Exposure (ADR-0080) the same way `AccessWithExposure`/`RequireExposedAccess` already do for reads: absent from the caller's own home-set, a `PUT` to the collection 404s — it was never there to write into. This is why the answer is `repository.ErrNotFound`, not a 403: a Calendar the caller can't see behaves identically whether the write is refused or the Calendar never existed. Once Exposure says yes, the ordinary write guard (`requireWritableCalendar`'s Access clamp, `requireLiveConnection`'s dead-grant refusal) applies exactly as it does to the REST API's `Create`/`Update`/`Delete`/`AddException` — this ADR adds no new authorization rule, only a new caller of the existing one. + +`DeleteCalendarObject` needed no new write logic at all: `EventService.Delete` already builds the `events.delete` push for a writable Linked Calendar's Master (ADR-0077). Only its *visibility* check (`currentObjectETag`) needed to stop blanket-refusing a Connection Source and start asking Exposure instead. + +## Threading the plan into `upsertSeries` + +`upsertSeries` is shared by four callers (`PutSeries`, `writeSeries`, `createSubscribedSeries`, `updateSubscribedSeries`), only one of which ever targets a writable Linked Calendar. Rather than forking the write path, `upsertSeries` takes an optional `*putSeriesWriteBack` plan — nil for the other three — and enqueues each push in the same transaction as the row it accompanies (ADR-0018), right after that row is written and its id is known: the Master's own push after `Create`/`Update`, an Override's `INSTANCE` push after its own `Create`/`Update` (using the freshly-minted id on a create), and every Exdate's `CANCEL_INSTANCE` push after the exdate-replace block. A rolled-back write therefore can never leave a push queued — the two are the same commit. + +## Why the Notification + +ADR-0079's thesis is that *"a mechanism that reports nothing is indistinguishable from a system with nothing to report."* A phone receives success for the local write and has no protocol affordance to be told later that the push to Google failed. Folding a marker into the serialized object is not an option: stored columns are the sole source of truth (ADR-0026), and a marker in a summary would return on the next `PUT` and corrupt the Event's title. + +So `markWriteBackPermanentlyFailed` — already responsible for the per-Event marker and the Source's needs-attention state — now also raises a Notification (ADR-0061), widening `kind` with `writeback_failed`. **Coalesced on the Source, not the Event**: `notifications` gains a nullable `calendar_id` (mirroring `event_id` becoming nullable for exactly this one kind) and a partial unique index on `(user_id, calendar_id) WHERE kind = 'writeback_failed'`, so a dead grant failing every one of its queued pushes at once raises exactly one Notification — re-raising upserts the existing row (fresh title, fresh `fired_at`, `seen` reset) rather than adding a second. This also closes the same gap for web-app edits, which today are discoverable only by noticing the marker on the grid. + +## Known edge — recorded, not fixed + +**"This and following" from a CalDAV client.** The protocol has no such operation, so a client performs it as two unrelated `PUT`s: the master gains an `UNTIL`, then a new Calendar object appears under a new `UID`. Both map onto existing pushes (a Master `PATCH`, then a `POST` for the new series), so Google ends up correct — but the local re-parent of Overrides past the split point never happens, and they remain on the now-bounded master. This is the same shape ADR-0078 already recorded for the web app's own "this and following" split, extended here to a client that reaches the same seam through two separate `PUT`s instead of one coordinated frontend action. + +## Seams + +The boundary sits at the outbox, matching how the codebase already divides Write-back testing (ADR-0078's own precedent): `caldavserver` over real HTTP asserts a `PUT`/`DELETE` queued the right outbox rows (`writeback_put_test.go`); `service` asserts `PutSeries`' diff against stored state produces the right plan (`event_put_series_writeback_test.go`), reusing `event_writeback_test.go`/`event_scoped_writeback_test.go`'s own fixtures. No new seams, and deliberately no fixture wiring Google into the CalDAV env — the send side (resolving an instance id from `events.instances`, the PATCH actually reaching Google) is unchanged from ADR-0078 and already covered there. + +## Consequences + +- **`ErrLinkedCalendarWriteUnsupported` is unaffected** — it still names the writes this ADR doesn't touch (an Attendee via Create, a CalDAV-shaped `ImportSeries` write, a Create/Update/`ReparentFrom` crossing a Connection Calendar's boundary). `PutSeries` never calls `ImportSeries`, so a CalDAV `PUT` was never subject to it in the first place; this ADR replaces `PutSeries`' own separate blanket refusal instead. +- **A new sentinel, `ErrLinkedCalendarWriteBackRevertUnsupported`**, names the two refused deltas — mapped to `403` in `caldavserver` (mirroring `ErrCalendarReadOnly`'s own mapping) rather than folded into the existing one, since the two refusals mean something different (a supported Calendar, an unsupported *edit shape*) from a Calendar that's read-only outright. +- **`ErrConnectionNeedsReconnect` reaches CalDAV for the first time**, mapped to `409` — mirroring the REST API's own mapping for the same sentinel (`handlers/event.go`) — for both `PutCalendarObject` and `DeleteCalendarObject`. +- **The Owner is, in practice, the only principal who reaches this path.** ADR-0075 clamps a Share on a Linked Calendar to Viewer, so `requireWritableCalendar`'s Access clamp already excludes an Editor accessor from ever reaching `buildPutSeriesWriteBack` — Exposure decides *visibility* for every principal (an accessor's own default is exposed, ADR-0080), but only the Owner's Access ever clears the write bar. diff --git a/server/internal/caldavserver/object.go b/server/internal/caldavserver/object.go index df24afb..868375f 100644 --- a/server/internal/caldavserver/object.go +++ b/server/internal/caldavserver/object.go @@ -118,14 +118,13 @@ func (b *Backend) currentObjectETag(ctx context.Context, userID int64, calendarI return false, "", nil } - // A Linked Calendar's object path answers not-exists here regardless of - // Exposure (ADR-0080) or the Source's mode: both the PUT precondition - // and DeleteCalendarObject must treat it as a 404 rather than letting - // EventService.Delete's own writable-Linked-Calendar path run from a - // CalDAV request, which would bypass Write-back entirely (#292). - // PutSeries takes the same posture directly. Exposure can make the - // collection itself discoverable (#297); accepting an object write or - // delete on it is #299's to build. + // A Linked Calendar's object path answers not-exists exactly when + // Exposure says so (ADR-0080, #299, ADR-0081) — mirroring + // GetCalendar/AccessWithExposure's own posture, not a blanket refusal: + // PutSeries and EventService.Delete each apply their own write guard + // (Access, the Connection's own liveness, PutSeries' diff refusals) + // once the object is visible at all. This is visibility only; a Viewer + // Share on an exposed Linked Calendar still 403s the write itself. cal, err := b.calendars.Get(ctx, userID, calendarID) if err != nil { if errors.Is(err, repository.ErrNotFound) { @@ -134,7 +133,14 @@ func (b *Backend) currentObjectETag(ctx context.Context, userID int64, calendarI return false, "", fmt.Errorf("get calendar: %w", err) } if cal.Source != nil && cal.Source.Kind == repository.SourceKindConnection { - return false, "", nil + isOwner := cal.UserID == userID + exposed, err := b.calendars.ResolveExposure(ctx, userID, cal, isOwner) + if err != nil { + return false, "", fmt.Errorf("resolve calendar exposure: %w", err) + } + if !exposed { + return false, "", nil + } } ics, _, err := icalendar.SeriesToICal(master, overrides, icalendar.CalDAVTarget(attachmentsURIPrefix(ctx))) @@ -188,14 +194,19 @@ func seriesWriteFromParsed(parsed *icalendar.ParsedSeries) service.SeriesWrite { // mapPutSeriesError maps PutSeries' validation/lookup errors onto the HTTP // status a CalDAV client expects: a bad request body is 400, an unresolved // calendar or an id naming an Override is 404 (matching GetCalendarObject), -// and a Subscribed Calendar's collection is 403 (ADR-0032) — it exists and -// is visible, the write is simply refused. +// a Subscribed Calendar's collection or a delta this app can't yet push to a +// writable Linked Calendar's Provider is 403 (ADR-0032, #299/ADR-0081) — +// the collection exists and is visible, the write is simply refused — and a +// Linked Calendar whose Connection needs reconnecting is 409, mirroring the +// REST API's own mapping (handlers/event.go) for the same sentinel. func mapPutSeriesError(err error) error { switch { - case errors.Is(err, service.ErrCalendarReadOnly): + case errors.Is(err, service.ErrCalendarReadOnly), errors.Is(err, service.ErrLinkedCalendarWriteBackRevertUnsupported): return webdav.NewHTTPError(http.StatusForbidden, err) case errors.Is(err, service.ErrCalendarNotFound), errors.Is(err, service.ErrParentIsOverride), errors.Is(err, repository.ErrNotFound): return webdav.NewHTTPError(http.StatusNotFound, err) + case errors.Is(err, service.ErrConnectionNeedsReconnect): + return webdav.NewHTTPError(http.StatusConflict, err) case errors.Is(err, service.ErrInvalidTitle), errors.Is(err, service.ErrInvalidTimeRange), errors.Is(err, service.ErrInvalidRecurrenceRule), @@ -248,6 +259,12 @@ func (b *Backend) DeleteCalendarObject(ctx context.Context, path string) error { if errors.Is(err, service.ErrCalendarReadOnly) { return webdav.NewHTTPError(http.StatusForbidden, err) } + // Mirrors mapPutSeriesError's own mapping for the same sentinel + // (#299, ADR-0081) — a Connection whose grant needs reconnecting + // refuses a new Write-back rather than queuing one that can't land. + if errors.Is(err, service.ErrConnectionNeedsReconnect) { + return webdav.NewHTTPError(http.StatusConflict, err) + } return fmt.Errorf("delete calendar object: %w", err) } return nil diff --git a/server/internal/caldavserver/writeback_put_test.go b/server/internal/caldavserver/writeback_put_test.go new file mode 100644 index 0000000..640d8b0 --- /dev/null +++ b/server/internal/caldavserver/writeback_put_test.go @@ -0,0 +1,364 @@ +package caldavserver + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/XiovV/calich/server/internal/icalendar" + "github.com/XiovV/calich/server/internal/repository" +) + +// This file covers the CalDAV surface of #299/ADR-0081: a PUT (or DELETE) +// reaching a writable Linked Calendar's Provider only once Exposure has made +// the collection visible at all, over real HTTP — the diff itself is +// event_put_series_writeback_test.go's, at the EventService seam this +// package's Backend calls straight into. + +// putSeriesOnCalendar PUTs master (with no Overrides) at calendarID and +// returns the raw response, mirroring putSeriesStatus but keeping the whole +// *http.Response so a caller can also inspect headers. +func putSeriesOnCalendar(t *testing.T, env testCalDAVEnv, calendarID string, master repository.Event) *http.Response { + t.Helper() + return putSeriesWithOverrides(t, env, calendarID, master, nil) +} + +// putSeriesWithOverrides is putSeriesOnCalendar with Overrides — master's own +// Rrule/Exdates fields carry the RRULE/EXDATE lines, and each override's +// RecurrenceID anchors it to the Occurrence it replaces (ADR-0016), exactly +// as a real client's whole-object PUT would encode them. +func putSeriesWithOverrides(t *testing.T, env testCalDAVEnv, calendarID string, master repository.Event, overrides []repository.Event) *http.Response { + t.Helper() + cal, _, err := icalendar.SeriesToICal(master, overrides, icalendar.CalDAVTarget(attachmentsBasePath)) + if err != nil { + t.Fatalf("seriesToICal: %v", err) + } + return rawPut(t, env, calendarObjectPath(env.userID, calendarID, master.ID), cal, "") +} + +func newTestPutMaster(id string) repository.Event { + return repository.Event{ + ID: id, + Title: "Standup", + Start: time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC), + End: time.Date(2026, 6, 1, 9, 30, 0, 0, time.UTC), + CreatedAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), + } +} + +func pendingWriteBackRows(t *testing.T, env testCalDAVEnv) []repository.OutboxMessage { + t.Helper() + rows, err := repository.NewOutboxRepository(env.db).ListPending(context.Background(), 200) + if err != nil { + t.Fatalf("list pending outbox rows: %v", err) + } + var matched []repository.OutboxMessage + for _, m := range rows { + if m.Kind == repository.OutboxKindWriteBack { + matched = append(matched, m) + } + } + return matched +} + +func acceptPutStatus(t *testing.T, resp *http.Response) { + t.Helper() + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 201 or 204, got %d", resp.StatusCode) + } +} + +// TestPutCalendarObject_ExposedWritableLinkedCalendar_QueuesCreateWriteBack +// covers #299's acceptance criterion that a create from a real CalDAV PUT +// reaches Google: once Exposure is on and the Source is writable, a PUT to a +// brand-new object queues an events.insert (POST) rather than answering 404 +// the way it did before ADR-0081. +func TestPutCalendarObject_ExposedWritableLinkedCalendar_QueuesCreateWriteBack(t *testing.T) { + env := newTestCalDAVEnv(t) + linkedID := uuid.NewString() + env.createLinkedCalendarWithMode(t, linkedID, "Linked", repository.SourceModeWritable) + if err := env.calendarService.SetExposure(context.Background(), env.userID, linkedID, true); err != nil { + t.Fatalf("expose linked calendar: %v", err) + } + + master := newTestPutMaster("device-uid-create-1") + resp := putSeriesOnCalendar(t, env, linkedID, master) + defer resp.Body.Close() + acceptPutStatus(t, resp) + + pending := pendingWriteBackRows(t, env) + if len(pending) != 1 || pending[0].Method != repository.OutboxMethodPost { + t.Fatalf("expected exactly one POST write-back, got %+v", pending) + } +} + +// TestPutCalendarObject_UnexposedWritableLinkedCalendar_404s covers +// ADR-0081's exposure gate directly over HTTP: a writable Linked Calendar +// the Owner hasn't turned Exposure on for isn't in their home-set to PUT +// into at all, so the object path 404s exactly like GetCalendar's own +// posture (ADR-0080) — and nothing is queued. +func TestPutCalendarObject_UnexposedWritableLinkedCalendar_404s(t *testing.T) { + env := newTestCalDAVEnv(t) + linkedID := uuid.NewString() + env.createLinkedCalendarWithMode(t, linkedID, "Linked", repository.SourceModeWritable) + // Deliberately left unexposed — the Owner's own default (ADR-0080). + + master := newTestPutMaster("device-uid-unexposed-1") + resp := putSeriesOnCalendar(t, env, linkedID, master) + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } + if pending := pendingWriteBackRows(t, env); len(pending) != 0 { + t.Fatalf("expected nothing queued, got %+v", pending) + } +} + +// TestPutCalendarObject_ReadOnlyLinkedCalendar_403sEvenWhenExposed covers +// that Exposure only makes a Linked Calendar's collection visible — it +// never overrides the Source's own read-only Mode (ADR-0075/ADR-0080's own +// "Access resolves first, Exposure second"). Once exposed, a read-only +// Source's object is visible enough to 403 rather than 404, matching a +// Subscribed Calendar's own posture. +func TestPutCalendarObject_ReadOnlyLinkedCalendar_403sEvenWhenExposed(t *testing.T) { + env := newTestCalDAVEnv(t) + linkedID := uuid.NewString() + env.createLinkedCalendarWithMode(t, linkedID, "Linked", repository.SourceModeReadOnly) + if err := env.calendarService.SetExposure(context.Background(), env.userID, linkedID, true); err != nil { + t.Fatalf("expose linked calendar: %v", err) + } + + master := newTestPutMaster("device-uid-readonly-1") + resp := putSeriesOnCalendar(t, env, linkedID, master) + defer resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + if pending := pendingWriteBackRows(t, env); len(pending) != 0 { + t.Fatalf("expected nothing queued, got %+v", pending) + } +} + +// TestDeleteCalendarObject_ExposedWritableLinkedCalendar_QueuesDeleteWriteBack +// covers #299's acceptance criterion that a delete from a real CalDAV DELETE +// reaches Google: EventService.Delete already builds the events.delete push +// for a writable Linked Calendar's Master (ADR-0077); this asserts the +// object path actually reaches it once Exposure says the collection is +// visible. +func TestDeleteCalendarObject_ExposedWritableLinkedCalendar_QueuesDeleteWriteBack(t *testing.T) { + env := newTestCalDAVEnv(t) + linkedID := uuid.NewString() + env.createLinkedCalendarWithMode(t, linkedID, "Linked", repository.SourceModeWritable) + if err := env.calendarService.SetExposure(context.Background(), env.userID, linkedID, true); err != nil { + t.Fatalf("expose linked calendar: %v", err) + } + + master := newTestPutMaster("device-uid-delete-1") + putResp := putSeriesOnCalendar(t, env, linkedID, master) + putResp.Body.Close() + acceptPutStatus(t, putResp) + + delResp := rawDelete(t, env, calendarObjectPath(env.userID, linkedID, master.ID), "") + defer delResp.Body.Close() + if delResp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204, got %d", delResp.StatusCode) + } + + var deleteRows int + for _, m := range pendingWriteBackRows(t, env) { + if m.Method == repository.OutboxMethodDelete { + deleteRows++ + } + } + if deleteRows != 1 { + t.Fatalf("expected exactly one DELETE write-back, got %d", deleteRows) + } +} + +// TestDeleteCalendarObject_UnexposedWritableLinkedCalendar_404s mirrors +// TestPutCalendarObject_UnexposedWritableLinkedCalendar_404s for DELETE. +func TestDeleteCalendarObject_UnexposedWritableLinkedCalendar_404s(t *testing.T) { + env := newTestCalDAVEnv(t) + linkedID := uuid.NewString() + env.createLinkedCalendarWithMode(t, linkedID, "Linked", repository.SourceModeWritable) + + resp := rawDelete(t, env, calendarObjectPath(env.userID, linkedID, "device-uid-missing"), "") + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } +} + +// soleWriteBackByMethod returns env's one pending write-back row of method, +// failing the test if there isn't exactly one — the real-HTTP counterpart to +// event_put_series_writeback_test.go's soleInstanceWriteBack, for asserting +// which push a real CalDAV PUT queued rather than just that one was queued. +func soleWriteBackByMethod(t *testing.T, env testCalDAVEnv, method string) repository.OutboxMessage { + t.Helper() + var matched []repository.OutboxMessage + for _, m := range pendingWriteBackRows(t, env) { + if m.Method == method { + matched = append(matched, m) + } + } + if len(matched) != 1 { + t.Fatalf("expected exactly one %s write-back, got %d: %+v", method, len(matched), matched) + } + return matched[0] +} + +// TestPutCalendarObject_EditedMaster_QueuesPatchWriteBack covers the "Master +// fields changed" row of #299's delta table over real HTTP: a PUT to an +// object that already carries an ExternalUID (as one whose earlier +// events.insert already landed at Google would) queues a PATCH rather than +// another POST — this package deliberately wires no fake Google server in +// (ADR-0081's own "Seams" note), so the create having "already landed" is +// simulated the same way event_put_series_writeback_test.go's +// seedLinkedMaster fixture does, via a direct repository write. +func TestPutCalendarObject_EditedMaster_QueuesPatchWriteBack(t *testing.T) { + env := newTestCalDAVEnv(t) + linkedID := uuid.NewString() + env.createLinkedCalendarWithMode(t, linkedID, "Linked", repository.SourceModeWritable) + if err := env.calendarService.SetExposure(context.Background(), env.userID, linkedID, true); err != nil { + t.Fatalf("expose linked calendar: %v", err) + } + + master := newTestPutMaster("device-uid-edit-1") + createResp := putSeriesOnCalendar(t, env, linkedID, master) + createResp.Body.Close() + acceptPutStatus(t, createResp) + + etag := "etag-already-linked" + if err := repository.NewEventRepository(env.db).AdoptProviderIdentity(context.Background(), master.ID, "google-event-1", &etag); err != nil { + t.Fatalf("adopt provider identity: %v", err) + } + // The create push above is otherwise still pending — its own dedup would + // reuse that row unchanged rather than let the edit below queue a fresh + // PATCH (harmless in practice, since SendWriteBack re-derives create-vs- + // edit from the Event's own live ExternalUID rather than the row's + // stored Method — but this test wants to name the row's method + // explicitly). Mark it sent, as if it had actually landed. + if _, err := env.db.Exec(`UPDATE outbox SET status = 'sent' WHERE event_id = ? AND kind = 'writeback'`, master.ID); err != nil { + t.Fatalf("mark create push sent: %v", err) + } + + master.Title = "Standup (renamed from a phone)" + editResp := putSeriesOnCalendar(t, env, linkedID, master) + defer editResp.Body.Close() + acceptPutStatus(t, editResp) + + soleWriteBackByMethod(t, env, repository.OutboxMethodPatch) +} + +// TestPutCalendarObject_AddsOverride_QueuesInstanceWriteBack covers the +// "Override added" row of #299's delta table over real HTTP: a PUT adding a +// recurring series' first Override queues an INSTANCE push naming that +// Occurrence's RECURRENCE-ID. +func TestPutCalendarObject_AddsOverride_QueuesInstanceWriteBack(t *testing.T) { + env := newTestCalDAVEnv(t) + linkedID := uuid.NewString() + env.createLinkedCalendarWithMode(t, linkedID, "Linked", repository.SourceModeWritable) + if err := env.calendarService.SetExposure(context.Background(), env.userID, linkedID, true); err != nil { + t.Fatalf("expose linked calendar: %v", err) + } + + master := newTestPutMaster("device-uid-override-add-1") + master.Rrule = "FREQ=WEEKLY" + createResp := putSeriesOnCalendar(t, env, linkedID, master) + createResp.Body.Close() + acceptPutStatus(t, createResp) + + recurrenceID := master.Start.AddDate(0, 0, 7) + override := repository.Event{ + Title: "Standup (moved)", + Start: recurrenceID.Add(time.Hour), + End: recurrenceID.Add(90 * time.Minute), + RecurrenceID: &recurrenceID, + } + editResp := putSeriesWithOverrides(t, env, linkedID, master, []repository.Event{override}) + defer editResp.Body.Close() + acceptPutStatus(t, editResp) + + msg := soleWriteBackByMethod(t, env, repository.OutboxMethodInstance) + if !msg.WriteBackInstance.RecurrenceID.Equal(recurrenceID) { + t.Fatalf("expected recurrence id %v, got %v", recurrenceID, msg.WriteBackInstance.RecurrenceID) + } +} + +// TestPutCalendarObject_AddsExdate_QueuesCancelInstanceWriteBack covers the +// "EXDATE added" row of #299's delta table over real HTTP: a PUT adding one +// EXDATE to a recurring series — "delete this event" on an ordinary +// Occurrence, the shape a real client encodes that scope as — queues a +// CANCEL_INSTANCE push naming it. +func TestPutCalendarObject_AddsExdate_QueuesCancelInstanceWriteBack(t *testing.T) { + env := newTestCalDAVEnv(t) + linkedID := uuid.NewString() + env.createLinkedCalendarWithMode(t, linkedID, "Linked", repository.SourceModeWritable) + if err := env.calendarService.SetExposure(context.Background(), env.userID, linkedID, true); err != nil { + t.Fatalf("expose linked calendar: %v", err) + } + + master := newTestPutMaster("device-uid-exdate-add-1") + master.Rrule = "FREQ=WEEKLY" + createResp := putSeriesOnCalendar(t, env, linkedID, master) + createResp.Body.Close() + acceptPutStatus(t, createResp) + + exdate := master.Start.AddDate(0, 0, 7) + master.Exdates = []time.Time{exdate} + editResp := putSeriesOnCalendar(t, env, linkedID, master) + defer editResp.Body.Close() + acceptPutStatus(t, editResp) + + msg := soleWriteBackByMethod(t, env, repository.OutboxMethodCancelInstance) + if !msg.WriteBackInstance.RecurrenceID.Equal(exdate) { + t.Fatalf("expected recurrence id %v, got %v", exdate, msg.WriteBackInstance.RecurrenceID) + } +} + +// TestPutCalendarObject_RevertsOverride_403sAndLeavesSeriesUnchanged covers +// the "Override removed (Occurrence reverts to the rule)" row of #299's +// delta table over real HTTP: a PUT that drops an existing Override with no +// matching new Exdate — asking to revert that Occurrence to the rule, which +// this app cannot yet push to Google — 403s, and the stored Override +// survives untouched (ADR-0081's "refused before any local write"). +func TestPutCalendarObject_RevertsOverride_403sAndLeavesSeriesUnchanged(t *testing.T) { + env := newTestCalDAVEnv(t) + linkedID := uuid.NewString() + env.createLinkedCalendarWithMode(t, linkedID, "Linked", repository.SourceModeWritable) + if err := env.calendarService.SetExposure(context.Background(), env.userID, linkedID, true); err != nil { + t.Fatalf("expose linked calendar: %v", err) + } + + master := newTestPutMaster("device-uid-revert-1") + master.Rrule = "FREQ=WEEKLY" + recurrenceID := master.Start.AddDate(0, 0, 7) + override := repository.Event{ + Title: "Standup (moved)", + Start: recurrenceID.Add(time.Hour), + End: recurrenceID.Add(90 * time.Minute), + RecurrenceID: &recurrenceID, + } + createResp := putSeriesWithOverrides(t, env, linkedID, master, []repository.Event{override}) + createResp.Body.Close() + acceptPutStatus(t, createResp) + + // Resend with the Override simply gone and no matching Exdate — a + // revert, not a delete. + revertResp := putSeriesOnCalendar(t, env, linkedID, master) + defer revertResp.Body.Close() + if revertResp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", revertResp.StatusCode) + } + + _, overrides, err := env.eventService.GetSeries(context.Background(), env.userID, master.ID) + if err != nil { + t.Fatalf("get series after refusal: %v", err) + } + if len(overrides) != 1 { + t.Fatalf("expected the refused PUT to leave the stored override in place, got %+v", overrides) + } +} diff --git a/server/internal/db/migrations/00008_writeback_failure_notification.sql b/server/internal/db/migrations/00008_writeback_failure_notification.sql new file mode 100644 index 0000000..e791dd7 --- /dev/null +++ b/server/internal/db/migrations/00008_writeback_failure_notification.sql @@ -0,0 +1,76 @@ +-- +goose Up +-- A Write-back that permanently fails on a writable Linked Calendar raises a +-- Notification, not only the existing per-Event marker and Source +-- needs-attention state (#299, ADR-0081). ADR-0079's own thesis — "a +-- mechanism that reports nothing is indistinguishable from a system with +-- nothing to report" — applies here to a phone: it received success for the +-- local write and has no protocol affordance to be told later the push to +-- Google failed. +-- +-- Coalesced on the Source, not the Event: a dead grant fails every one of +-- its queued pushes at once, and one Notification per Event would mean +-- dozens landing for a single underlying cause. So this widens the existing +-- term (ADR-0061) rather than adding a table: kind gains 'writeback_failed', +-- event_id becomes nullable (a writeback_failed Notification concerns a +-- Calendar, not any one Event — occurrence_start already has the same +-- "not every kind needs this" shape), and a new calendar_id column names +-- which one. The partial unique index below on (user_id, calendar_id) WHERE +-- kind = 'writeback_failed' is what performs the coalescing: re-raising the +-- same Source's failure upserts the same row (fresh title, fresh fired_at, +-- seen reset) rather than inserting a second one. +-- +-- SQLite cannot alter a CHECK or add a partial unique index to an existing +-- table in place, so this rebuilds the table wholesale (ADR-0048's +-- append-only era), copying every existing row forward unchanged — every one +-- is 'reminder' or 'invite', both keeping their NOT NULL event_id and NULL +-- calendar_id. +CREATE TABLE notifications_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + event_id TEXT REFERENCES events(id) ON DELETE CASCADE, + calendar_id TEXT REFERENCES calendars(id) ON DELETE CASCADE, + kind TEXT NOT NULL DEFAULT 'reminder' CHECK (kind IN ('reminder', 'invite', 'writeback_failed')), + occurrence_start TIMESTAMP, + title TEXT NOT NULL, + fired_at TIMESTAMP NOT NULL, + seen BOOLEAN NOT NULL DEFAULT 0, + CHECK ( + (kind IN ('reminder', 'invite') AND event_id IS NOT NULL AND calendar_id IS NULL) + OR + (kind = 'writeback_failed' AND calendar_id IS NOT NULL AND event_id IS NULL) + ) +); + +INSERT INTO notifications_new (id, user_id, event_id, kind, occurrence_start, title, fired_at, seen) +SELECT id, user_id, event_id, kind, occurrence_start, title, fired_at, seen +FROM notifications; + +DROP TABLE notifications; +ALTER TABLE notifications_new RENAME TO notifications; + +CREATE INDEX idx_notifications_user_id ON notifications(user_id, fired_at DESC); +CREATE UNIQUE INDEX idx_notifications_writeback_failure ON notifications(user_id, calendar_id) WHERE kind = 'writeback_failed'; + +-- +goose Down +-- 'writeback_failed' rows are dropped: the old schema's event_id is NOT +-- NULL and these rows carry none, so there is no lossless way back. +CREATE TABLE notifications_old ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + event_id TEXT NOT NULL REFERENCES events(id) ON DELETE CASCADE, + kind TEXT NOT NULL DEFAULT 'reminder' CHECK (kind IN ('reminder', 'invite')), + occurrence_start TIMESTAMP, + title TEXT NOT NULL, + fired_at TIMESTAMP NOT NULL, + seen BOOLEAN NOT NULL DEFAULT 0 +); + +INSERT INTO notifications_old (id, user_id, event_id, kind, occurrence_start, title, fired_at, seen) +SELECT id, user_id, event_id, kind, occurrence_start, title, fired_at, seen +FROM notifications +WHERE kind IN ('reminder', 'invite'); + +DROP TABLE notifications; +ALTER TABLE notifications_old RENAME TO notifications; + +CREATE INDEX idx_notifications_user_id ON notifications(user_id, fired_at DESC); diff --git a/server/internal/handlers/notification.go b/server/internal/handlers/notification.go index 22c0ffc..274b64c 100644 --- a/server/internal/handlers/notification.go +++ b/server/internal/handlers/notification.go @@ -18,13 +18,15 @@ func NewNotificationHandler(notifications *service.NotificationService) *Notific return &NotificationHandler{notifications: notifications} } -// notificationResponse is a Notification's wire shape (ADR-0021, ADR-0061) -// — the in-app feed panel's list item. OccurrenceStart is omitted for an -// invite Notification, which concerns the Event series rather than one -// Occurrence. +// notificationResponse is a Notification's wire shape (ADR-0021, ADR-0061, +// ADR-0081) — the in-app feed panel's list item. OccurrenceStart is omitted +// for an invite or writeback_failed Notification, neither of which concerns +// one Occurrence. EventID is omitted for writeback_failed, which concerns a +// Calendar instead — CalendarID is present only there. type notificationResponse struct { ID int64 `json:"id"` - EventID string `json:"eventId"` + EventID *string `json:"eventId,omitempty"` + CalendarID *string `json:"calendarId,omitempty"` Kind string `json:"kind"` Title string `json:"title"` OccurrenceStart *string `json:"occurrenceStart,omitempty"` @@ -42,6 +44,7 @@ func toNotificationResponse(n repository.Notification) notificationResponse { return notificationResponse{ ID: n.ID, EventID: n.EventID, + CalendarID: n.CalendarID, Kind: n.Kind, Title: n.Title, OccurrenceStart: occurrenceStart, diff --git a/server/internal/handlers/notification_test.go b/server/internal/handlers/notification_test.go index ca4a12b..9c76f60 100644 --- a/server/internal/handlers/notification_test.go +++ b/server/internal/handlers/notification_test.go @@ -101,7 +101,7 @@ func TestNotificationHandler_ListReturnsRecentNotificationsNewestFirst(t *testin if len(got) != 1 { t.Fatalf("expected 1 notification, got %+v", got) } - if got[0].Title != "Standup" || got[0].EventID != eventID || got[0].Seen { + if got[0].Title != "Standup" || got[0].EventID == nil || *got[0].EventID != eventID || got[0].Seen { t.Fatalf("unexpected notification: %+v", got[0]) } if got[0].Kind != "reminder" || got[0].OccurrenceStart == nil { diff --git a/server/internal/reminder/dispatch_test.go b/server/internal/reminder/dispatch_test.go index 0b73c59..7137bc9 100644 --- a/server/internal/reminder/dispatch_test.go +++ b/server/internal/reminder/dispatch_test.go @@ -16,7 +16,7 @@ type fakeNotificationInserter struct { } func (f *fakeNotificationInserter) Insert(_ context.Context, userID int64, eventID string, occurrenceStart time.Time, title string, firedAt time.Time) (repository.Notification, error) { - n := repository.Notification{UserID: userID, EventID: eventID, OccurrenceStart: &occurrenceStart, Title: title, FiredAt: firedAt} + n := repository.Notification{UserID: userID, EventID: &eventID, OccurrenceStart: &occurrenceStart, Title: title, FiredAt: firedAt} f.inserts = append(f.inserts, n) return n, nil } @@ -45,7 +45,7 @@ func TestNotificationDispatcher_NotificationChannelInsertsANotification(t *testi t.Fatalf("expected 1 notification insert, got %+v", inserter.inserts) } got := inserter.inserts[0] - if got.UserID != 7 || got.EventID != "evt-1" || got.Title != "Standup" || !got.FiredAt.Equal(fixedNow) { + if got.UserID != 7 || got.EventID == nil || *got.EventID != "evt-1" || got.Title != "Standup" || !got.FiredAt.Equal(fixedNow) { t.Fatalf("unexpected inserted notification: %+v", got) } if len(fallback.dispatched) != 0 { diff --git a/server/internal/repository/notification.go b/server/internal/repository/notification.go index 281ce6e..a3953cf 100644 --- a/server/internal/repository/notification.go +++ b/server/internal/repository/notification.go @@ -9,22 +9,31 @@ import ( // Notification kind values (ADR-0061): what produced this record — // KindReminder for a fired Notification-Channel Reminder, KindInvite for -// being made an Attendee of an Event. +// being made an Attendee of an Event, KindWriteBackFailed for a Write-back +// push that permanently failed on a writable Linked Calendar (#299, +// ADR-0081) — coalesced on the Source rather than raised per Event, so a +// dead grant failing dozens of queued pushes at once still raises exactly +// one. const ( - KindReminder = "reminder" - KindInvite = "invite" + KindReminder = "reminder" + KindInvite = "invite" + KindWriteBackFailed = "writeback_failed" ) // Notification is the persistent in-app record of something that concerns -// one User (ADR-0021, ADR-0061). Title is a copy of the Event's title at -// write time, not a live join, so a Notification keeps reading correctly -// even after the Event is later edited. OccurrenceStart is meaningful only -// for Kind == KindReminder — an invite concerns the Event series rather -// than one Occurrence, so it's nil there. +// one User (ADR-0021, ADR-0061). Title is a copy of the Event's (or, for +// KindWriteBackFailed, the Calendar's) title at write time, not a live join, +// so a Notification keeps reading correctly even after that row is later +// edited. EventID is nil only for KindWriteBackFailed, which concerns a +// Calendar rather than any one Event; CalendarID is nil for every other +// kind. OccurrenceStart is meaningful only for Kind == KindReminder — every +// other kind concerns a whole series or a whole Calendar, not one +// Occurrence, so it's nil there. type Notification struct { ID int64 UserID int64 - EventID string + EventID *string + CalendarID *string Kind string OccurrenceStart *time.Time Title string @@ -69,7 +78,7 @@ func (r *NotificationRepository) Insert(ctx context.Context, userID int64, event return Notification{ ID: id, UserID: userID, - EventID: eventID, + EventID: &eventID, Kind: KindReminder, OccurrenceStart: &occurrenceStart, Title: title, @@ -98,7 +107,7 @@ func (r *NotificationRepository) InsertInvite(ctx context.Context, userID int64, return Notification{ ID: id, UserID: userID, - EventID: eventID, + EventID: &eventID, Kind: KindInvite, Title: title, FiredAt: invitedAt, @@ -106,11 +115,43 @@ func (r *NotificationRepository) InsertInvite(ctx context.Context, userID int64, }, nil } +// UpsertWriteBackFailure records that userID's Write-back to calendarID's +// Provider has permanently failed (#299, ADR-0081, ADR-0079's own thesis +// applied to a phone with no protocol affordance to be told later). Coalesced +// on (user_id, calendar_id) via the partial unique index migration 00008 +// creates: a Source whose grant is dead fails every one of its queued pushes +// at once, so re-raising the same failure upserts the existing row — fresh +// title, fresh firedAt, seen reset to false — rather than adding a second +// one that would make "how many things are wrong" indistinguishable from +// "how many pushes failed". +func (r *NotificationRepository) UpsertWriteBackFailure(ctx context.Context, userID int64, calendarID, title string, firedAt time.Time) (Notification, error) { + _, err := r.db.ExecContext(ctx, + `INSERT INTO notifications (user_id, calendar_id, kind, title, fired_at, seen) + VALUES (?, ?, ?, ?, ?, 0) + ON CONFLICT (user_id, calendar_id) WHERE kind = 'writeback_failed' + DO UPDATE SET title = excluded.title, fired_at = excluded.fired_at, seen = 0`, + userID, calendarID, KindWriteBackFailed, title, firedAt.UTC(), + ) + if err != nil { + return Notification{}, fmt.Errorf("upsert write-back failure notification: %w", err) + } + + n, err := scanNotificationRow(r.db.QueryRowContext(ctx, + `SELECT id, user_id, event_id, calendar_id, kind, occurrence_start, title, fired_at, seen + FROM notifications WHERE user_id = ? AND calendar_id = ? AND kind = ?`, + userID, calendarID, KindWriteBackFailed, + )) + if err != nil { + return Notification{}, fmt.Errorf("get upserted write-back failure notification: %w", err) + } + return n, nil +} + // ListRecentByUser returns userID's most recent Notifications, newest first, // capped at limit. func (r *NotificationRepository) ListRecentByUser(ctx context.Context, userID int64, limit int) ([]Notification, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, user_id, event_id, kind, occurrence_start, title, fired_at, seen + `SELECT id, user_id, event_id, calendar_id, kind, occurrence_start, title, fired_at, seen FROM notifications WHERE user_id = ? ORDER BY fired_at DESC, id DESC LIMIT ?`, userID, limit, ) @@ -122,10 +163,17 @@ func (r *NotificationRepository) ListRecentByUser(ctx context.Context, userID in func scanNotificationRow(row rowScanner) (Notification, error) { var n Notification + var eventID, calendarID sql.NullString var occurrenceStart sql.NullTime - if err := row.Scan(&n.ID, &n.UserID, &n.EventID, &n.Kind, &occurrenceStart, &n.Title, &n.FiredAt, &n.Seen); err != nil { + if err := row.Scan(&n.ID, &n.UserID, &eventID, &calendarID, &n.Kind, &occurrenceStart, &n.Title, &n.FiredAt, &n.Seen); err != nil { return Notification{}, err } + if eventID.Valid { + n.EventID = &eventID.String + } + if calendarID.Valid { + n.CalendarID = &calendarID.String + } if occurrenceStart.Valid { n.OccurrenceStart = &occurrenceStart.Time } diff --git a/server/internal/repository/notification_test.go b/server/internal/repository/notification_test.go index 88616f7..c095bd6 100644 --- a/server/internal/repository/notification_test.go +++ b/server/internal/repository/notification_test.go @@ -9,8 +9,9 @@ import ( ) // newTestNotificationRepository returns a NotificationRepository plus two -// real user ids and a real event id to satisfy notifications' foreign keys. -func newTestNotificationRepository(t *testing.T) (repo *NotificationRepository, userID, otherUserID int64, eventID string) { +// real user ids, a real event id, and a real calendar id to satisfy +// notifications' foreign keys. +func newTestNotificationRepository(t *testing.T) (repo *NotificationRepository, userID, otherUserID int64, eventID, calendarID string) { t.Helper() sqlDB, err := db.OpenInMemory() @@ -48,11 +49,11 @@ func newTestNotificationRepository(t *testing.T) (repo *NotificationRepository, events := NewEventRepository(sqlDB) mustCreateEvent(t, events, "evt-1", user.ID, cal.ID, "2026-01-01T09:00:00Z", "2026-01-01T10:00:00Z") - return NewNotificationRepository(sqlDB), user.ID, other.ID, "evt-1" + return NewNotificationRepository(sqlDB), user.ID, other.ID, "evt-1", cal.ID } func TestNotificationRepository_InsertAndListRecentByUser(t *testing.T) { - repo, userID, otherUserID, eventID := newTestNotificationRepository(t) + repo, userID, otherUserID, eventID, _ := newTestNotificationRepository(t) ctx := context.Background() occurrenceStart := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC) @@ -87,13 +88,13 @@ func TestNotificationRepository_InsertAndListRecentByUser(t *testing.T) { if len(list) != 1 { t.Fatalf("expected 1 notification for userID, got %d: %+v", len(list), list) } - if list[0].Title != "Standup" || list[0].EventID != eventID { + if list[0].Title != "Standup" || list[0].EventID == nil || *list[0].EventID != eventID { t.Fatalf("unexpected notification content: %+v", list[0]) } } func TestNotificationRepository_ListRecentByUserOrdersNewestFirstAndRespectsLimit(t *testing.T) { - repo, userID, _, eventID := newTestNotificationRepository(t) + repo, userID, _, eventID, _ := newTestNotificationRepository(t) ctx := context.Background() base := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC) @@ -116,7 +117,7 @@ func TestNotificationRepository_ListRecentByUserOrdersNewestFirstAndRespectsLimi } func TestNotificationRepository_MarkAllSeen(t *testing.T) { - repo, userID, otherUserID, eventID := newTestNotificationRepository(t) + repo, userID, otherUserID, eventID, _ := newTestNotificationRepository(t) ctx := context.Background() occurrenceStart := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC) @@ -157,7 +158,7 @@ func TestNotificationRepository_MarkAllSeen(t *testing.T) { // one Occurrence, so occurrence_start round-trips as nil rather than a // zero-value time. func TestNotificationRepository_InsertInviteHasNilOccurrenceStart(t *testing.T) { - repo, userID, _, eventID := newTestNotificationRepository(t) + repo, userID, _, eventID, _ := newTestNotificationRepository(t) ctx := context.Background() invitedAt := time.Date(2026, 1, 1, 8, 0, 0, 0, time.UTC) @@ -191,7 +192,7 @@ func TestNotificationRepository_InsertInviteHasNilOccurrenceStart(t *testing.T) // covers ADR-0061's "one feed" decision: reminder and invite Notifications // share the same table, list, and newest-first ordering. func TestNotificationRepository_ListRecentByUserMixesReminderAndInviteKinds(t *testing.T) { - repo, userID, _, eventID := newTestNotificationRepository(t) + repo, userID, _, eventID, _ := newTestNotificationRepository(t) ctx := context.Background() occurrenceStart := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC) @@ -216,3 +217,72 @@ func TestNotificationRepository_ListRecentByUserMixesReminderAndInviteKinds(t *t t.Fatalf("expected the reminder second, got %+v", list) } } + +// TestNotificationRepository_UpsertWriteBackFailureCoalescesOnCalendar covers +// #299/ADR-0081's central property: re-raising the same Calendar's +// Write-back failure — the shape a dead grant failing dozens of queued +// pushes at once produces — upserts the one existing row rather than adding +// a second, and un-seats it back to unseen even if the User had already +// seen the first report. +func TestNotificationRepository_UpsertWriteBackFailureCoalescesOnCalendar(t *testing.T) { + repo, userID, otherUserID, _, calendarID := newTestNotificationRepository(t) + ctx := context.Background() + + first, err := repo.UpsertWriteBackFailure(ctx, userID, calendarID, `"Work" could not be synced to Google`, time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("upsert write-back failure: %v", err) + } + if first.Kind != KindWriteBackFailed { + t.Fatalf("expected kind %q, got %q", KindWriteBackFailed, first.Kind) + } + if first.EventID != nil { + t.Fatalf("expected a nil event id, got %v", first.EventID) + } + if first.CalendarID == nil || *first.CalendarID != calendarID { + t.Fatalf("expected calendar id %q, got %v", calendarID, first.CalendarID) + } + if first.OccurrenceStart != nil { + t.Fatalf("expected a nil occurrence start, got %v", first.OccurrenceStart) + } + + if err := repo.MarkAllSeen(ctx, userID); err != nil { + t.Fatalf("mark all seen: %v", err) + } + + second, err := repo.UpsertWriteBackFailure(ctx, userID, calendarID, `"Work" could not be synced to Google`, time.Date(2026, 1, 1, 9, 5, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("upsert write-back failure again: %v", err) + } + if second.ID != first.ID { + t.Fatalf("expected the same row id on a second failure for the same calendar, got %d then %d", first.ID, second.ID) + } + if second.Seen { + t.Fatalf("expected re-raising the failure to reset seen to false") + } + + list, err := repo.ListRecentByUser(ctx, userID, 10) + if err != nil { + t.Fatalf("list recent: %v", err) + } + var writeBackFailures int + for _, n := range list { + if n.Kind == KindWriteBackFailed { + writeBackFailures++ + } + } + if writeBackFailures != 1 { + t.Fatalf("expected exactly one coalesced write-back-failed notification, got %d in %+v", writeBackFailures, list) + } + + // A different user's own Notifications must not coalesce with userID's. + if _, err := repo.UpsertWriteBackFailure(ctx, otherUserID, calendarID, `"Work" could not be synced to Google`, time.Date(2026, 1, 1, 9, 10, 0, 0, time.UTC)); err != nil { + t.Fatalf("upsert write-back failure for other user: %v", err) + } + otherList, err := repo.ListRecentByUser(ctx, otherUserID, 10) + if err != nil { + t.Fatalf("list recent for other user: %v", err) + } + if len(otherList) != 1 { + t.Fatalf("expected the other user to have their own single notification, got %+v", otherList) + } +} diff --git a/server/internal/service/attendee_test.go b/server/internal/service/attendee_test.go index 2a3f36f..ed4818e 100644 --- a/server/internal/service/attendee_test.go +++ b/server/internal/service/attendee_test.go @@ -245,7 +245,7 @@ func TestEventService_AddAttendee_WritesInviteNotification(t *testing.T) { if notifications[0].Kind != repository.KindInvite { t.Fatalf("expected kind %q, got %q", repository.KindInvite, notifications[0].Kind) } - if notifications[0].EventID != event.ID || notifications[0].Title != event.Title { + if notifications[0].EventID == nil || *notifications[0].EventID != event.ID || notifications[0].Title != event.Title { t.Fatalf("expected the notification to name the event, got %+v", notifications[0]) } if notifications[0].OccurrenceStart != nil { diff --git a/server/internal/service/connection.go b/server/internal/service/connection.go index cfc7649..0e241d8 100644 --- a/server/internal/service/connection.go +++ b/server/internal/service/connection.go @@ -78,6 +78,13 @@ type ConnectionService struct { // already stores" and "apply this reconciled result" don't care which // Source kind produced the incoming side. events *EventService + // notifications is where markWriteBackPermanentlyFailed raises a + // coalesced-on-the-Source Notification alongside the existing per-Event + // marker and Source needs-attention state (#299, ADR-0081) — the same + // repository EventService's own Attendee-invite path writes through, + // threaded here directly since that path has no passthrough for an + // arbitrary Notification write. + notifications *repository.NotificationRepository // encryptionKey is config.Config.ConnectionsEncryptionKey — a refresh // token is encrypted under it before Upsert and never stored raw // (ADR-0052). @@ -149,13 +156,14 @@ func withConnectionRefreshInterval(d time.Duration) ConnectionOption { // there is exactly one place that decides whether the Google Provider is // usable, and Settings' Connect button (which reads the same GoogleConfigured // call) can never disagree with what Connect/Callback actually refuse. -func NewConnectionService(connections *repository.ConnectionRepository, states connectStateCodec, calendars *CalendarService, events *EventService, clientID, clientSecret, encryptionKey string, configured bool, opts ...ConnectionOption) *ConnectionService { +func NewConnectionService(connections *repository.ConnectionRepository, states connectStateCodec, calendars *CalendarService, events *EventService, notifications *repository.NotificationRepository, clientID, clientSecret, encryptionKey string, configured bool, opts ...ConnectionOption) *ConnectionService { s := &ConnectionService{ connections: connections, states: states, google: newGoogleClient(clientID, clientSecret), calendars: calendars, events: events, + notifications: notifications, encryptionKey: encryptionKey, configured: configured, now: time.Now, diff --git a/server/internal/service/connection_picker_test.go b/server/internal/service/connection_picker_test.go index d9f69c9..2927b5d 100644 --- a/server/internal/service/connection_picker_test.go +++ b/server/internal/service/connection_picker_test.go @@ -43,7 +43,7 @@ func newTestConnectionServiceWithGraph(t *testing.T, google *fakeGoogleServer, o withGoogleEndpoints(google.URL+"/authorize", google.URL+"/token", google.URL+"/userinfo", google.URL+"/calendarList"), withGoogleEventsURL(google.URL), }, opts...) - svc = NewConnectionService(connections, g.Auth, g.Calendars, g.Events, "test-client-id", "test-client-secret", "test-encryption-key", true, allOpts...) + svc = NewConnectionService(connections, g.Auth, g.Calendars, g.Events, g.NotificationRepo, "test-client-id", "test-client-secret", "test-encryption-key", true, allOpts...) return svc, g.Auth, user.ID, workspace.ID, g } diff --git a/server/internal/service/connection_test.go b/server/internal/service/connection_test.go index 4048406..deedae7 100644 --- a/server/internal/service/connection_test.go +++ b/server/internal/service/connection_test.go @@ -458,7 +458,7 @@ func newTestConnectionService(t *testing.T, google *fakeGoogleServer) (*Connecti } connections := repository.NewConnectionRepository(g.DB) - svc := NewConnectionService(connections, g.Auth, g.Calendars, g.Events, "test-client-id", "test-client-secret", "test-encryption-key", true, + svc := NewConnectionService(connections, g.Auth, g.Calendars, g.Events, g.NotificationRepo, "test-client-id", "test-client-secret", "test-encryption-key", true, withGoogleHTTPClient(google.Client()), withGoogleEndpoints(google.URL+"/authorize", google.URL+"/token", google.URL+"/userinfo", google.URL+"/calendarList"), withGoogleEventsURL(google.URL), diff --git a/server/internal/service/connection_writeback.go b/server/internal/service/connection_writeback.go index e2e07c7..a162d6c 100644 --- a/server/internal/service/connection_writeback.go +++ b/server/internal/service/connection_writeback.go @@ -640,16 +640,18 @@ func (s *ConnectionService) reconcileProviderOwnedFields(ctx context.Context, id // markWriteBackPermanentlyFailed records a Write-back push that will never // reach the Provider on its own (#291, ADR-0075, ADR-0076): a per-Event -// marker (EventService.MarkWriteBackFailed) so the grid can show it, and the +// marker (EventService.MarkWriteBackFailed) so the grid can show it, the // affected Linked Calendar's Source raised into needs-attention // (CalendarService.RecordWriteBackFailure) so the sidebar's existing // broken-Source badge — already rendered for a failed Refresh — picks this -// up too. Tolerant of the Event or its Calendar having vanished since (a -// concurrent delete, an unshared or moved Calendar): there is nothing left -// to mark, which is success, not failure. Always returns nil on the -// happy/tolerant path so every caller can hand it straight back to the -// outbox Worker as "msg is handled" — retrying a push that would only ever -// fail the same way again serves nobody. +// up too, and a Notification (#299, ADR-0081) so a phone, which has no +// protocol affordance to learn its own edit never reached Google, has +// somewhere to learn it at all. Tolerant of the Event or its Calendar having +// vanished since (a concurrent delete, an unshared or moved Calendar): there +// is nothing left to mark, which is success, not failure. Always returns nil +// on the happy/tolerant path so every caller can hand it straight back to +// the outbox Worker as "msg is handled" — retrying a push that would only +// ever fail the same way again serves nobody. func (s *ConnectionService) markWriteBackPermanentlyFailed(ctx context.Context, userID int64, eventID, calendarID, reason string) error { if err := s.events.MarkWriteBackFailed(ctx, eventID, reason); err != nil && !errors.Is(err, repository.ErrNotFound) { return fmt.Errorf("mark event write-back failed: %w", err) @@ -657,6 +659,32 @@ func (s *ConnectionService) markWriteBackPermanentlyFailed(ctx context.Context, if err := s.calendars.RecordWriteBackFailure(ctx, userID, calendarID, ErrorClassNeedsAttention, reason); err != nil && !errors.Is(err, repository.ErrNotFound) { return fmt.Errorf("record write-back failure on source: %w", err) } + if err := s.raiseWriteBackFailureNotification(ctx, userID, calendarID); err != nil { + return err + } + return nil +} + +// raiseWriteBackFailureNotification upserts the coalesced-on-the-Source +// Notification markWriteBackPermanentlyFailed's own doc comment describes +// (#299, ADR-0081): re-raising the same Calendar's failure — a dead grant +// failing every one of its queued pushes in the same instant — upserts the +// one existing row (repository.NotificationRepository.UpsertWriteBackFailure's +// own partial unique index) rather than adding a Notification per Event. +// Tolerant of the Calendar having vanished since, mirroring every other step +// markWriteBackPermanentlyFailed already takes. +func (s *ConnectionService) raiseWriteBackFailureNotification(ctx context.Context, userID int64, calendarID string) error { + calendar, err := s.calendars.GetByIDUnchecked(ctx, calendarID) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return nil + } + return fmt.Errorf("load calendar for write-back failure notification: %w", err) + } + title := fmt.Sprintf("%q could not be synced to Google", calendar.Name) + if _, err := s.notifications.UpsertWriteBackFailure(ctx, userID, calendarID, title, s.now()); err != nil { + return fmt.Errorf("raise write-back failure notification: %w", err) + } return nil } diff --git a/server/internal/service/connection_writeback_test.go b/server/internal/service/connection_writeback_test.go index 064433a..326f5a8 100644 --- a/server/internal/service/connection_writeback_test.go +++ b/server/internal/service/connection_writeback_test.go @@ -33,7 +33,7 @@ func newTestConnectionServiceForWriteBack(t *testing.T, google *fakeGoogleServer } connections := repository.NewConnectionRepository(g.DB) - svc = NewConnectionService(connections, g.Auth, g.Calendars, g.Events, "test-client-id", "test-client-secret", "test-encryption-key", true, + svc = NewConnectionService(connections, g.Auth, g.Calendars, g.Events, g.NotificationRepo, "test-client-id", "test-client-secret", "test-encryption-key", true, withGoogleHTTPClient(google.Client()), withGoogleEndpoints(google.URL+"/authorize", google.URL+"/token", google.URL+"/userinfo", google.URL+"/calendarList"), withGoogleEventsURL(google.URL), @@ -382,6 +382,31 @@ func TestConnectionService_SendWriteBack_MarksPermanentlyFailedAfterThreeConflic if source.ErrorClass == nil || *source.ErrorClass != ErrorClassNeedsAttention { t.Fatalf("expected the Source raised into needs-attention, got %v", source.ErrorClass) } + + // #299/ADR-0081: the same permanent failure also raises a Notification, + // coalesced on the Calendar rather than the Event — the only surface a + // phone (no per-Event marker of its own to look at) has to learn its + // edit never reached Google. + notifications, err := g.NotificationRepo.ListRecentByUser(ctx, userID, 10) + if err != nil { + t.Fatalf("list notifications: %v", err) + } + var writeBackFailures int + for _, n := range notifications { + if n.Kind != repository.KindWriteBackFailed { + continue + } + writeBackFailures++ + if n.CalendarID == nil || *n.CalendarID != calendar.ID { + t.Fatalf("expected the notification to name calendar %q, got %v", calendar.ID, n.CalendarID) + } + if n.EventID != nil { + t.Fatalf("expected a nil event id, got %v", n.EventID) + } + } + if writeBackFailures != 1 { + t.Fatalf("expected exactly one write-back-failed notification, got %d in %+v", writeBackFailures, notifications) + } } // TestConnectionService_SendWriteBack_ExpiresConnectionAndMarksFailedOnDeadRefreshToken diff --git a/server/internal/service/event.go b/server/internal/service/event.go index 4062aab..35076e7 100644 --- a/server/internal/service/event.go +++ b/server/internal/service/event.go @@ -99,6 +99,15 @@ var ( // or events.patch status:cancelled), and split a series ("this and // following", performed by hand — see ReparentFrom). ErrLinkedCalendarWriteUnsupported = errors.New("this kind of write is not yet supported on a linked calendar") + // ErrLinkedCalendarWriteBackRevertUnsupported is returned by PutSeries + // when a CalDAV PUT to a writable Linked Calendar would revert an + // Override back to its Master's rule, or un-cancel an Exception (#299, + // ADR-0081): both are expressible at Google (patch an instance back to + // the master's values; patch a cancelled instance back to confirmed) but + // deliberately deferred, being the two rarest things a phone does. + // Returned before any local write, so the refused PUT leaves the stored + // series unchanged rather than partially applying. + ErrLinkedCalendarWriteBackRevertUnsupported = errors.New("restoring a cancelled or overridden occurrence is not yet supported on a linked calendar") // ErrConnectionNeedsReconnect is returned by Update when write.CalendarID // carries a Connection-kind Source whose Connection has moved off // ConnectionStatusLive (#291, ADR-0075): a Connection that no longer diff --git a/server/internal/service/event_create_attendee_test.go b/server/internal/service/event_create_attendee_test.go index bff2dcb..98f45d6 100644 --- a/server/internal/service/event_create_attendee_test.go +++ b/server/internal/service/event_create_attendee_test.go @@ -167,7 +167,7 @@ func TestEventService_Create_InvitesExplicitUsers_WritesOneInviteNotificationEac if len(notifications) != 1 { t.Fatalf("expected 1 notification for member %d, got %+v", memberID, notifications) } - if notifications[0].Kind != repository.KindInvite || notifications[0].EventID != event.ID || notifications[0].Title != event.Title { + if notifications[0].Kind != repository.KindInvite || notifications[0].EventID == nil || *notifications[0].EventID != event.ID || notifications[0].Title != event.Title { t.Fatalf("unexpected notification for member %d: %+v", memberID, notifications[0]) } } diff --git a/server/internal/service/event_put_series_writeback_test.go b/server/internal/service/event_put_series_writeback_test.go new file mode 100644 index 0000000..6ca316d --- /dev/null +++ b/server/internal/service/event_put_series_writeback_test.go @@ -0,0 +1,360 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/XiovV/calich/server/internal/repository" +) + +// This file covers PutSeries' own compiler (#299, ADR-0081): a CalDAV PUT +// diffed against a writable Linked Calendar's stored state, turned into the +// right outbox rows (or refused outright, atomically) before anything is +// written. The seam matches event_scoped_writeback_test.go's own: which +// outbox row a write produces, and that a delta this app can't yet express +// refuses cleanly — not what SendWriteBack does with the row once queued. + +// exposeLinkedCalendar turns Exposure on for userID's own Linked Calendar +// (ADR-0080) — every PutSeries test needs this, since a Linked Calendar's own +// Owner defaults to *unexposed*, and PutSeries answers repository.ErrNotFound +// for a PUT to a collection that isn't in the caller's home-set at all. +func exposeLinkedCalendar(t *testing.T, g *Graph, userID int64, calendarID string) { + t.Helper() + if err := g.Calendars.SetExposure(context.Background(), userID, calendarID, true); err != nil { + t.Fatalf("expose linked calendar: %v", err) + } +} + +func basicSeriesWrite(title string, start, end time.Time) SeriesWrite { + return SeriesWrite{Title: title, Start: start, End: end} +} + +func TestEventService_PutSeries_NewSeries_EnqueuesCreate(t *testing.T) { + g := newTestGraph(t) + ctx := context.Background() + userID, calendarID := newTestLinkedCalendar(t, g, repository.SourceModeWritable) + exposeLinkedCalendar(t, g, userID, calendarID) + + start := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC) + end := start.Add(30 * time.Minute) + masterID := "put-new-master" + _, _, err := g.Events.PutSeries(ctx, userID, calendarID, masterID, basicSeriesWrite("Standup", start, end)) + if err != nil { + t.Fatalf("put series: %v", err) + } + + pending := pendingWriteBacks(t, g, masterID) + if len(pending) != 1 || pending[0].Method != repository.OutboxMethodPost { + t.Fatalf("expected exactly one POST write-back, got %+v", pending) + } +} + +func TestEventService_PutSeries_MasterFieldsChanged_EnqueuesPatch(t *testing.T) { + g := newTestGraph(t) + ctx := context.Background() + userID, calendarID := newTestLinkedCalendar(t, g, repository.SourceModeWritable) + exposeLinkedCalendar(t, g, userID, calendarID) + master := seedLinkedMaster(t, g, userID, calendarID, "put-patch-master") + + _, _, err := g.Events.PutSeries(ctx, userID, calendarID, master.ID, basicSeriesWrite("Standup (renamed)", master.Start, master.End)) + if err != nil { + t.Fatalf("put series: %v", err) + } + + pending := pendingWriteBacks(t, g, master.ID) + if len(pending) != 1 || pending[0].Method != repository.OutboxMethodPatch { + t.Fatalf("expected exactly one PATCH write-back, got %+v", pending) + } +} + +func TestEventService_PutSeries_MasterUnchanged_EnqueuesNothing(t *testing.T) { + g := newTestGraph(t) + ctx := context.Background() + userID, calendarID := newTestLinkedCalendar(t, g, repository.SourceModeWritable) + exposeLinkedCalendar(t, g, userID, calendarID) + master := seedLinkedMaster(t, g, userID, calendarID, "put-noop-master") + + _, _, err := g.Events.PutSeries(ctx, userID, calendarID, master.ID, basicSeriesWrite(master.Title, master.Start, master.End)) + if err != nil { + t.Fatalf("put series: %v", err) + } + + if pending := pendingWriteBacks(t, g, master.ID); len(pending) != 0 { + t.Fatalf("expected no write-back for an unchanged PUT, got %+v", pending) + } +} + +func TestEventService_PutSeries_OverrideAdded_EnqueuesInstance(t *testing.T) { + g := newTestGraph(t) + ctx := context.Background() + userID, calendarID := newTestLinkedCalendar(t, g, repository.SourceModeWritable) + exposeLinkedCalendar(t, g, userID, calendarID) + master := seedLinkedRecurringMaster(t, g, userID, calendarID, "put-override-add-master") + + recurrenceID := master.Start.AddDate(0, 0, 7) + write := basicSeriesWrite(master.Title, master.Start, master.End) + write.Rrule = master.Rrule + write.Overrides = []OverrideWrite{{ + RecurrenceID: recurrenceID, + Title: "Standup (moved)", + Start: recurrenceID.Add(time.Hour), + End: recurrenceID.Add(90 * time.Minute), + }} + + _, overrides, err := g.Events.PutSeries(ctx, userID, calendarID, master.ID, write) + if err != nil { + t.Fatalf("put series: %v", err) + } + if len(overrides) != 1 { + t.Fatalf("expected exactly one stored override, got %+v", overrides) + } + + msg := soleInstanceWriteBack(t, g, master.ID, repository.OutboxMethodInstance) + if msg.WriteBackInstance.OverrideEventID != overrides[0].ID { + t.Fatalf("expected the instance push to name the new override %q, got %q", overrides[0].ID, msg.WriteBackInstance.OverrideEventID) + } + if !msg.WriteBackInstance.RecurrenceID.Equal(recurrenceID) { + t.Fatalf("expected recurrence id %v, got %v", recurrenceID, msg.WriteBackInstance.RecurrenceID) + } +} + +func TestEventService_PutSeries_OverrideChanged_EnqueuesInstance(t *testing.T) { + g := newTestGraph(t) + ctx := context.Background() + userID, calendarID := newTestLinkedCalendar(t, g, repository.SourceModeWritable) + exposeLinkedCalendar(t, g, userID, calendarID) + master := seedLinkedRecurringMaster(t, g, userID, calendarID, "put-override-change-master") + recurrenceID := master.Start.AddDate(0, 0, 7) + override := seedLinkedOverride(t, g, userID, calendarID, master.ID, "put-override-change-override", recurrenceID) + + write := basicSeriesWrite(master.Title, master.Start, master.End) + write.Rrule = master.Rrule + write.Overrides = []OverrideWrite{{ + RecurrenceID: recurrenceID, + Title: "Standup (retitled)", + Start: override.Start, + End: override.End, + }} + + _, _, err := g.Events.PutSeries(ctx, userID, calendarID, master.ID, write) + if err != nil { + t.Fatalf("put series: %v", err) + } + + msg := soleInstanceWriteBack(t, g, master.ID, repository.OutboxMethodInstance) + if msg.WriteBackInstance.OverrideEventID != override.ID { + t.Fatalf("expected the instance push to name the existing override %q, got %q", override.ID, msg.WriteBackInstance.OverrideEventID) + } +} + +func TestEventService_PutSeries_OverrideUnchanged_EnqueuesNothing(t *testing.T) { + g := newTestGraph(t) + ctx := context.Background() + userID, calendarID := newTestLinkedCalendar(t, g, repository.SourceModeWritable) + exposeLinkedCalendar(t, g, userID, calendarID) + master := seedLinkedRecurringMaster(t, g, userID, calendarID, "put-override-noop-master") + recurrenceID := master.Start.AddDate(0, 0, 7) + override := seedLinkedOverride(t, g, userID, calendarID, master.ID, "put-override-noop-override", recurrenceID) + + write := basicSeriesWrite(master.Title, master.Start, master.End) + write.Rrule = master.Rrule + write.Overrides = []OverrideWrite{{ + RecurrenceID: recurrenceID, + Title: override.Title, + Start: override.Start, + End: override.End, + }} + + _, _, err := g.Events.PutSeries(ctx, userID, calendarID, master.ID, write) + if err != nil { + t.Fatalf("put series: %v", err) + } + + if pending := pendingWriteBacks(t, g, master.ID); len(pending) != 0 { + t.Fatalf("expected no write-back for an unchanged override, got %+v", pending) + } +} + +func TestEventService_PutSeries_ExdateAdded_EnqueuesCancelInstance(t *testing.T) { + g := newTestGraph(t) + ctx := context.Background() + userID, calendarID := newTestLinkedCalendar(t, g, repository.SourceModeWritable) + exposeLinkedCalendar(t, g, userID, calendarID) + master := seedLinkedRecurringMaster(t, g, userID, calendarID, "put-exdate-add-master") + exdate := master.Start.AddDate(0, 0, 14) + + write := basicSeriesWrite(master.Title, master.Start, master.End) + write.Rrule = master.Rrule + write.Exdates = []time.Time{exdate} + + _, _, err := g.Events.PutSeries(ctx, userID, calendarID, master.ID, write) + if err != nil { + t.Fatalf("put series: %v", err) + } + + msg := soleInstanceWriteBack(t, g, master.ID, repository.OutboxMethodCancelInstance) + if msg.WriteBackInstance.OverrideEventID != "" { + t.Fatalf("expected no override event id on a plain exdate cancel, got %q", msg.WriteBackInstance.OverrideEventID) + } + if !msg.WriteBackInstance.RecurrenceID.Equal(exdate) { + t.Fatalf("expected recurrence id %v, got %v", exdate, msg.WriteBackInstance.RecurrenceID) + } +} + +func TestEventService_PutSeries_OverrideRemoved_RefusesAndLeavesSeriesUnchanged(t *testing.T) { + g := newTestGraph(t) + ctx := context.Background() + userID, calendarID := newTestLinkedCalendar(t, g, repository.SourceModeWritable) + exposeLinkedCalendar(t, g, userID, calendarID) + master := seedLinkedRecurringMaster(t, g, userID, calendarID, "put-override-revert-master") + recurrenceID := master.Start.AddDate(0, 0, 7) + seedLinkedOverride(t, g, userID, calendarID, master.ID, "put-override-revert-override", recurrenceID) + + // Resend the series with no Overrides and no Exdates at all — "revert + // this Occurrence to what the rule generates", which this app cannot + // yet push to Google. + write := basicSeriesWrite(master.Title, master.Start, master.End) + write.Rrule = master.Rrule + + _, _, err := g.Events.PutSeries(ctx, userID, calendarID, master.ID, write) + if !errors.Is(err, ErrLinkedCalendarWriteBackRevertUnsupported) { + t.Fatalf("expected ErrLinkedCalendarWriteBackRevertUnsupported, got %v", err) + } + + if pending := pendingWriteBacks(t, g, master.ID); len(pending) != 0 { + t.Fatalf("expected the refusal to queue nothing, got %+v", pending) + } + _, overrides, err := g.Events.GetSeries(ctx, userID, master.ID) + if err != nil { + t.Fatalf("get series after refusal: %v", err) + } + if len(overrides) != 1 { + t.Fatalf("expected the refused PUT to leave the stored override in place, got %+v", overrides) + } +} + +func TestEventService_PutSeries_ExdateRemoved_RefusesAndLeavesSeriesUnchanged(t *testing.T) { + g := newTestGraph(t) + ctx := context.Background() + userID, calendarID := newTestLinkedCalendar(t, g, repository.SourceModeWritable) + exposeLinkedCalendar(t, g, userID, calendarID) + master := seedLinkedRecurringMaster(t, g, userID, calendarID, "put-exdate-revert-master") + exdate := master.Start.AddDate(0, 0, 7) + if err := g.EventExceptionRepo.Add(ctx, master.ID, exdate); err != nil { + t.Fatalf("seed exdate: %v", err) + } + + // Resend the series with the Exdate simply gone — "un-cancel this + // Occurrence", also deferred. + write := basicSeriesWrite(master.Title, master.Start, master.End) + write.Rrule = master.Rrule + + _, _, err := g.Events.PutSeries(ctx, userID, calendarID, master.ID, write) + if !errors.Is(err, ErrLinkedCalendarWriteBackRevertUnsupported) { + t.Fatalf("expected ErrLinkedCalendarWriteBackRevertUnsupported, got %v", err) + } + + if pending := pendingWriteBacks(t, g, master.ID); len(pending) != 0 { + t.Fatalf("expected the refusal to queue nothing, got %+v", pending) + } + stored, err := g.EventExceptionRepo.ListByParentIDs(ctx, []string{master.ID}) + if err != nil { + t.Fatalf("list exceptions after refusal: %v", err) + } + if len(stored[master.ID]) != 1 { + t.Fatalf("expected the refused PUT to leave the stored exdate in place, got %+v", stored[master.ID]) + } +} + +// TestEventService_PutSeries_OverrideRemovedWithMatchingExdate_CancelsInstance +// covers the standard CalDAV shape for "delete this already-modified +// Occurrence": the Override VEVENT drops out of the object and an EXDATE +// line appears for its RECURRENCE-ID, both in the same PUT. This is not the +// "revert to the rule" refusal above — it's an ordinary cancellation, and +// must reach Google as one. +func TestEventService_PutSeries_OverrideRemovedWithMatchingExdate_CancelsInstance(t *testing.T) { + g := newTestGraph(t) + ctx := context.Background() + userID, calendarID := newTestLinkedCalendar(t, g, repository.SourceModeWritable) + exposeLinkedCalendar(t, g, userID, calendarID) + master := seedLinkedRecurringMaster(t, g, userID, calendarID, "put-override-delete-master") + recurrenceID := master.Start.AddDate(0, 0, 7) + seedLinkedOverride(t, g, userID, calendarID, master.ID, "put-override-delete-override", recurrenceID) + + write := basicSeriesWrite(master.Title, master.Start, master.End) + write.Rrule = master.Rrule + write.Exdates = []time.Time{recurrenceID} + + _, overrides, err := g.Events.PutSeries(ctx, userID, calendarID, master.ID, write) + if err != nil { + t.Fatalf("put series: %v", err) + } + if len(overrides) != 0 { + t.Fatalf("expected the override row to be gone, got %+v", overrides) + } + + msg := soleInstanceWriteBack(t, g, master.ID, repository.OutboxMethodCancelInstance) + if !msg.WriteBackInstance.RecurrenceID.Equal(recurrenceID) { + t.Fatalf("expected recurrence id %v, got %v", recurrenceID, msg.WriteBackInstance.RecurrenceID) + } +} + +func TestEventService_PutSeries_DeadConnection_ReturnsNeedsReconnect(t *testing.T) { + g := newTestGraph(t) + ctx := context.Background() + userID, calendarID := newTestLinkedCalendar(t, g, repository.SourceModeWritable) + exposeLinkedCalendar(t, g, userID, calendarID) + master := seedLinkedMaster(t, g, userID, calendarID, "put-dead-conn-master") + + cal, err := g.Calendars.GetByIDUnchecked(ctx, calendarID) + if err != nil { + t.Fatalf("get calendar: %v", err) + } + if err := g.ConnectionRepo.UpdateStatus(ctx, userID, *cal.Source.ConnectionID, repository.ConnectionStatusRevoked); err != nil { + t.Fatalf("revoke connection: %v", err) + } + + _, _, err = g.Events.PutSeries(ctx, userID, calendarID, master.ID, basicSeriesWrite("Renamed", master.Start, master.End)) + if !errors.Is(err, ErrConnectionNeedsReconnect) { + t.Fatalf("expected ErrConnectionNeedsReconnect, got %v", err) + } + if pending := pendingWriteBacks(t, g, master.ID); len(pending) != 0 { + t.Fatalf("expected no write-back queued, got %+v", pending) + } +} + +// TestEventService_PutSeries_PreservesProviderOwnedFields covers this +// ticket's own acceptance criterion, depending on #298: a phone's edit +// (a CalDAV PUT) must not blank the Provider RSVP, Conference URL, or guest +// count a Refresh previously stored, the same way EventService.Update +// already can't (#298 removed the columns from EventRepository.Update's SQL +// entirely; PutSeries' own upsertSeries call passes providerOwned=false, +// exactly as an ordinary edit does). +func TestEventService_PutSeries_PreservesProviderOwnedFields(t *testing.T) { + g := newTestGraph(t) + ctx := context.Background() + userID, calendarID := newTestLinkedCalendar(t, g, repository.SourceModeWritable) + exposeLinkedCalendar(t, g, userID, calendarID) + master := seedLinkedMaster(t, g, userID, calendarID, "put-preserve-provider-fields") + + _, _, err := g.Events.PutSeries(ctx, userID, calendarID, master.ID, basicSeriesWrite("Standup (renamed from a phone)", master.Start, master.End)) + if err != nil { + t.Fatalf("put series: %v", err) + } + + got, err := g.EventRepo.GetByID(ctx, master.ID) + if err != nil { + t.Fatalf("get event: %v", err) + } + if got.RSVPStatus == nil || *got.RSVPStatus != "accepted" { + t.Fatalf("expected RSVP status to survive, got %v", got.RSVPStatus) + } + if got.ConferenceURL == nil || *got.ConferenceURL != "https://meet.example.com/abc" { + t.Fatalf("expected conference url to survive, got %v", got.ConferenceURL) + } + if got.GuestCount != 3 { + t.Fatalf("expected guest count to survive, got %d", got.GuestCount) + } +} diff --git a/server/internal/service/event_series.go b/server/internal/service/event_series.go index e9af629..86a8af1 100644 --- a/server/internal/service/event_series.go +++ b/server/internal/service/event_series.go @@ -102,7 +102,7 @@ func (s *EventService) writeSeries(ctx context.Context, userID int64, calendarID } for _, w := range writes { - if err := s.upsertSeries(ctx, repos, calendarID, uuid.NewString(), userID, reminderUserID, seq, w, false, false); err != nil { + if err := s.upsertSeries(ctx, repos, calendarID, uuid.NewString(), userID, reminderUserID, seq, w, false, false, nil); err != nil { return err } } @@ -217,7 +217,7 @@ func (s *EventService) createSubscribedSeries(ctx context.Context, repos txRepos if err != nil { return err } - return s.upsertSeries(ctx, repos, calendarID, uuid.NewString(), userID, ownerID, seq, write, false, true) + return s.upsertSeries(ctx, repos, calendarID, uuid.NewString(), userID, ownerID, seq, write, false, true, nil) } // updateSubscribedSeries updates masterID's row and its Overrides in place @@ -235,7 +235,7 @@ func (s *EventService) updateSubscribedSeries(ctx context.Context, repos txRepos if err != nil { return err } - return s.upsertSeries(ctx, repos, calendarID, masterID, userID, ownerID, seq, write, false, true) + return s.upsertSeries(ctx, repos, calendarID, masterID, userID, ownerID, seq, write, false, true, nil) } // tombstoneSubscribedSeries deletes masterID outright (cascading to its @@ -566,7 +566,13 @@ func validateSeriesWrite(write *SeriesWrite) error { // Create branch — repository.EventRepository.Create's SQL still writes // these columns directly from f, correctly, since a freshly-minted row has // no prior value to protect. -func (s *EventService) upsertSeries(ctx context.Context, repos txRepos, calendarID, masterID string, userID, reminderUserID, seq int64, write SeriesWrite, markExplicit, providerOwned bool) error { +// wb is PutSeries' own Write-back plan (#299, ADR-0081), threading each Push +// a CalDAV PUT's diff decided into the same write that produces it: nil for +// every other caller (writeSeries, createSubscribedSeries, +// updateSubscribedSeries), none of which ever targets a writable Linked +// Calendar. See buildPutSeriesWriteBack's own doc comment for how it's +// computed. +func (s *EventService) upsertSeries(ctx context.Context, repos txRepos, calendarID, masterID string, userID, reminderUserID, seq int64, write SeriesWrite, markExplicit, providerOwned bool, wb *putSeriesWriteBack) error { // masterID missing is create-vs-update's only signal, deliberately — // including for updateSubscribedSeries's caller (ReconcileSubscribedSeries), // which believes masterID already exists: recreating a row unexpectedly @@ -603,6 +609,30 @@ func (s *EventService) upsertSeries(ctx context.Context, repos txRepos, calendar return fmt.Errorf("create master: %w", err) } } + + // PutSeries' own Master push (#299, ADR-0081): enqueued in the same + // transaction as the write it accompanies (ADR-0018). masterExists gates + // ClearWriteBackError the same way EventService.Update does — a brand-new + // row (Create's own case) has never carried a failure marker, so nothing + // to clear. + if wb != nil && wb.masterPush != "" { + switch wb.masterPush { + case repository.OutboxMethodPost: + if _, err := repos.writeback.EnqueueWriteBackCreate(ctx, masterID); err != nil { + return fmt.Errorf("enqueue write-back create: %w", err) + } + case repository.OutboxMethodPatch: + if _, err := repos.writeback.EnqueueWriteBack(ctx, masterID); err != nil { + return fmt.Errorf("enqueue write-back: %w", err) + } + } + if masterExists { + if err := repos.events.ClearWriteBackError(ctx, masterID); err != nil { + return fmt.Errorf("clear master write-back error: %w", err) + } + } + } + if err := repos.reminders.ReplaceByEventID(ctx, reminderUserID, masterID, write.Reminders); err != nil { return fmt.Errorf("persist master reminders: %w", err) } @@ -627,6 +657,22 @@ func (s *EventService) upsertSeries(ctx context.Context, repos txRepos, calendar } } + // PutSeries' own CANCEL_INSTANCE pushes (#299, ADR-0081): one per newly + // added Exdate, already resolved by buildPutSeriesWriteBack against the + // series' prior stored state — nothing left to diff here. Needs no + // Override row of its own (mirrors EventService.AddException). + if wb != nil { + for _, exdate := range wb.cancelExdates { + if _, err := repos.writeback.EnqueueWriteBackInstanceCancel(ctx, masterID, repository.OutboxWriteBackInstanceSnapshot{ + CalendarID: wb.calendarID, + MasterEventID: masterID, + RecurrenceID: exdate, + }); err != nil { + return fmt.Errorf("enqueue write-back instance cancel: %w", err) + } + } + } + var existingOverrides []repository.Event if masterExists { overridesByParent, err := repos.events.ListChildrenByParentIDs(ctx, []string{masterID}) @@ -668,6 +714,22 @@ func (s *EventService) upsertSeries(ctx context.Context, repos txRepos, calendar return fmt.Errorf("mark override reminders explicit: %w", err) } } + // PutSeries' own INSTANCE push for a changed Override (#299, + // ADR-0081) — mirrors EventService.Update's own instance-edit + // branch, including clearing the row's own failure marker. + if wb != nil && wb.instanceRecurrenceIDs[key] { + if _, err := repos.writeback.EnqueueWriteBackInstance(ctx, masterID, repository.OutboxWriteBackInstanceSnapshot{ + CalendarID: wb.calendarID, + MasterEventID: masterID, + RecurrenceID: o.RecurrenceID, + OverrideEventID: existing.ID, + }); err != nil { + return fmt.Errorf("enqueue write-back instance: %w", err) + } + if err := repos.events.ClearWriteBackError(ctx, existing.ID); err != nil { + return fmt.Errorf("clear override write-back error: %w", err) + } + } continue } @@ -683,6 +745,20 @@ func (s *EventService) upsertSeries(ctx context.Context, repos txRepos, calendar return fmt.Errorf("mark override reminders explicit: %w", err) } } + // PutSeries' own INSTANCE push for a brand-new Override (#299, + // ADR-0081) — mirrors EventService.Create's own instance-create + // branch. No failure marker to clear on a row that never existed + // before this write. + if wb != nil && wb.instanceRecurrenceIDs[key] { + if _, err := repos.writeback.EnqueueWriteBackInstance(ctx, masterID, repository.OutboxWriteBackInstanceSnapshot{ + CalendarID: wb.calendarID, + MasterEventID: masterID, + RecurrenceID: o.RecurrenceID, + OverrideEventID: overrideID, + }); err != nil { + return fmt.Errorf("enqueue write-back instance: %w", err) + } + } } for key, existing := range existingByRecurrenceID { @@ -703,6 +779,15 @@ func (s *EventService) upsertSeries(ctx context.Context, repos txRepos, calendar // whole write bumps change_seq exactly once, so CTag and sync-collection // see one atomic change (ADR-0018). Returns the written series recomposed // exactly as GetSeries would. +// +// On a writable Linked Calendar (#299, ADR-0081), the PUT is additionally +// compiled into Write-back pushes: buildPutSeriesWriteBack diffs write +// against the series' stored state *before* anything is written, and +// upsertSeries enqueues each resulting push in the same transaction as the +// row it accompanies (ADR-0018) — a rolled-back write must never leave a +// push queued. A delta this app cannot express at Google yet (reverting an +// Override, or un-cancelling an Exception) refuses the whole PUT up front, +// leaving the stored series untouched — see buildPutSeriesWriteBack. func (s *EventService) PutSeries(ctx context.Context, userID int64, calendarID, masterID string, write SeriesWrite) (repository.Event, []repository.Event, error) { if err := validateSeriesWrite(&write); err != nil { return repository.Event{}, nil, err @@ -712,20 +797,30 @@ func (s *EventService) PutSeries(ctx context.Context, userID int64, calendarID, if err != nil { return repository.Event{}, nil, err } - // A CalDAV PUT onto a Linked Calendar is refused outright, independent - // of Exposure (ADR-0080) and of the Source's mode: Write-back (ADR-0075) - // is the only path an edit reaches a Linked Calendar's Provider through, - // and letting a native client's PUT run EventService's ordinary write - // path here would bypass it entirely (#290, #292). Exposure can make a - // writable Linked Calendar's collection discoverable and its - // current-user-privilege-set advertise write (#297) — accepting a write - // there is #299's to build, not this refusal's to relax. Answers - // repository.ErrNotFound, not ErrLinkedCalendarWriteUnsupported, so a - // PUT to a guessed or cached path looks exactly like one aimed at a - // Calendar that never existed, matching GetCalendar's own posture rather - // than confirming the Calendar is there but refusing the write on it. + + var wb *putSeriesWriteBack if isConnectionSource(calendar) { - return repository.Event{}, nil, repository.ErrNotFound + // A CalDAV PUT reaches a Linked Calendar's Provider only when the + // requesting principal has turned Exposure on for it (ADR-0080) — + // the collection wasn't in their home-set to PUT into in the first + // place, so this answers repository.ErrNotFound exactly like the + // direct-path posture GetCalendar/AccessWithExposure already take, + // rather than confirming the Calendar exists but refusing the write + // on it. + isOwner := calendar.UserID == userID + exposed, err := s.calendars.ResolveExposure(ctx, userID, calendar, isOwner) + if err != nil { + return repository.Event{}, nil, err + } + if !exposed { + return repository.Event{}, nil, repository.ErrNotFound + } + // A Connection whose grant is no longer Live refuses new edits, same + // as Create/Update/Delete/AddException (#291, ADR-0075) — queuing a + // push against a dead grant only delays the failure. + if err := s.requireLiveConnection(ctx, calendar); err != nil { + return repository.Event{}, nil, err + } } existingMaster, err := s.getOwnedEvent(ctx, userID, masterID) @@ -740,6 +835,13 @@ func (s *EventService) PutSeries(ctx context.Context, userID int64, calendarID, return repository.Event{}, nil, repository.ErrNotFound } + if isConnectionSource(calendar) { + wb, err = s.buildPutSeriesWriteBack(ctx, calendarID, masterID, existingMaster, masterExists, write) + if err != nil { + return repository.Event{}, nil, err + } + } + err = s.withTx(ctx, func(repos txRepos) error { seq, err := repos.sync.NextChangeSeq(ctx) if err != nil { @@ -750,7 +852,7 @@ func (s *EventService) PutSeries(ctx context.Context, userID int64, calendarID, // PUTting User's own Reminders, not the Calendar's Owner's // (ADR-0064) — hence userID for both userID and reminderUserID, and // markExplicit true (see upsertSeries' doc comment). - return s.upsertSeries(ctx, repos, calendarID, masterID, userID, userID, seq, write, true, false) + return s.upsertSeries(ctx, repos, calendarID, masterID, userID, userID, seq, write, true, false, wb) }) if err != nil { return repository.Event{}, nil, fmt.Errorf("put series: %w", err) @@ -758,3 +860,169 @@ func (s *EventService) PutSeries(ctx context.Context, userID int64, calendarID, return s.GetSeries(ctx, userID, masterID) } + +// putSeriesWriteBack is PutSeries' own Write-back plan for a writable Linked +// Calendar (#299, ADR-0081) — what buildPutSeriesWriteBack's diff against +// stored state decided upsertSeries should enqueue, once per Master write. +// calendarID rides along since upsertSeries' own txRepos calls address the +// outbox by it, exactly as OutboxWriteBackInstanceSnapshot's own CalendarID +// field does. +type putSeriesWriteBack struct { + calendarID string + // masterPush is repository.OutboxMethodPost (the Master has never + // reached the Provider — a brand-new series, or one whose earlier + // events.insert hasn't landed yet), repository.OutboxMethodPatch (an + // already-linked Master whose pushed fields changed), or "" (nothing to + // push: an already-linked Master whose pushed fields are unchanged). + masterPush string + // instanceRecurrenceIDs is every Override this PUT adds or changes, + // keyed by RecurrenceID.UnixNano() — an INSTANCE push once its row + // (existing or freshly created) is written. + instanceRecurrenceIDs map[int64]bool + // cancelExdates is every Exdate this PUT newly adds — a CANCEL_INSTANCE + // push, needing no Override row of its own. + cancelExdates []time.Time +} + +// tzidEqual reports whether a and b name the same Anchor zone (ADR-0019), +// including both being nil (a Floating Event). +func tzidEqual(a, b *string) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} + +// masterFieldsDiffer reports whether any field Write-back actually pushes to +// Google (ADR-0075's PATCH-only list: title, start, end, allDay, tzid, rrule, +// description, location, url) differs between existing and write. Deliberately +// excludes Color and Reminders — neither is ever pushed (ADR-0075) — so a PUT +// that only recolours an Event or edits a Reminder queues nothing. +func masterFieldsDiffer(existing repository.Event, write SeriesWrite) bool { + return existing.Title != write.Title || + existing.Description != write.Description || + existing.Location != write.Location || + existing.URL != write.URL || + !existing.Start.Equal(write.Start) || + !existing.End.Equal(write.End) || + existing.AllDay != write.AllDay || + !tzidEqual(existing.Tzid, write.Tzid) || + existing.Rrule != write.Rrule +} + +// overrideFieldsDiffer is masterFieldsDiffer for one Override — the same +// pushed field set, minus Rrule, which an Override never carries (ADR-0016). +func overrideFieldsDiffer(existing repository.Event, write OverrideWrite) bool { + return existing.Title != write.Title || + existing.Description != write.Description || + existing.Location != write.Location || + existing.URL != write.URL || + !existing.Start.Equal(write.Start) || + !existing.End.Equal(write.End) || + existing.AllDay != write.AllDay || + !tzidEqual(existing.Tzid, write.Tzid) +} + +// buildPutSeriesWriteBack diffs write against masterID's currently stored +// state and compiles the result into a putSeriesWriteBack (#299, ADR-0081). +// Called, and must return cleanly, *before* PutSeries writes anything: a +// CalDAV PUT resends a recurring series' entire object on every save — the +// frontend's own scoped Series operations have no CalDAV equivalent — so +// without a genuine diff a single-Occurrence edit from a phone would also +// re-push an unchanged Master on every save. +// +// Four deltas map onto a push: +// - The Master's own pushed fields changed, or it has never reached the +// Provider at all (existingMaster.ExternalUID == nil, including the +// brand-new-series case where masterExists is false) — a Master push +// (POST or PATCH, decided by ExternalUID's presence exactly as +// EventService.Update decides it). +// - An incoming Override not present in stored state, or present but +// changed — an INSTANCE push. +// - An incoming Exdate not present in stored state — a CANCEL_INSTANCE +// push. Needs no Override row: mirrors AddException. +// +// Two are refused outright, before any of the above is even queued, because +// neither is expressible at Google yet (ADR-0081's known edge — "the two +// rarest things a phone does"): +// - A stored Exdate missing from write.Exdates ("un-cancel this +// Occurrence"). +// - A stored Override missing from write.Overrides whose RecurrenceID is +// *not* also a newly-added Exdate ("revert this Occurrence to the +// rule"). When it *is* also newly-added, this is not a revert — it's an +// ordinary "delete this already-modified Occurrence" (the standard +// CalDAV shape for that: the Override VEVENT drops out and an EXDATE +// line appears for its RECURRENCE-ID), already expressed above as that +// Exdate's own CANCEL_INSTANCE push. +func (s *EventService) buildPutSeriesWriteBack(ctx context.Context, calendarID, masterID string, existingMaster repository.Event, masterExists bool, write SeriesWrite) (*putSeriesWriteBack, error) { + wb := &putSeriesWriteBack{ + calendarID: calendarID, + instanceRecurrenceIDs: make(map[int64]bool, len(write.Overrides)), + } + + switch { + case !masterExists, existingMaster.ExternalUID == nil: + wb.masterPush = repository.OutboxMethodPost + case masterFieldsDiffer(existingMaster, write): + wb.masterPush = repository.OutboxMethodPatch + } + + var existingOverrides []repository.Event + var existingExdates []time.Time + if masterExists { + overridesByParent, err := s.events.ListChildrenByParentIDs(ctx, []string{masterID}) + if err != nil { + return nil, fmt.Errorf("list existing overrides for write-back diff: %w", err) + } + existingOverrides = overridesByParent[masterID] + + exdatesByParent, err := s.exceptions.ListByParentIDs(ctx, []string{masterID}) + if err != nil { + return nil, fmt.Errorf("list existing exdates for write-back diff: %w", err) + } + existingExdates = exdatesByParent[masterID] + } + + writeExdateSet := make(map[int64]bool, len(write.Exdates)) + for _, e := range write.Exdates { + writeExdateSet[e.UnixNano()] = true + } + + existingOverrideByKey := make(map[int64]repository.Event, len(existingOverrides)) + for _, o := range existingOverrides { + existingOverrideByKey[o.RecurrenceID.UnixNano()] = o + } + writeOverrideKeys := make(map[int64]bool, len(write.Overrides)) + for _, o := range write.Overrides { + key := o.RecurrenceID.UnixNano() + writeOverrideKeys[key] = true + if existing, ok := existingOverrideByKey[key]; !ok || overrideFieldsDiffer(existing, o) { + wb.instanceRecurrenceIDs[key] = true + } + } + for key := range existingOverrideByKey { + if writeOverrideKeys[key] { + continue + } + if !writeExdateSet[key] { + return nil, ErrLinkedCalendarWriteBackRevertUnsupported + } + } + + existingExdateSet := make(map[int64]bool, len(existingExdates)) + for _, e := range existingExdates { + existingExdateSet[e.UnixNano()] = true + } + for _, e := range write.Exdates { + if !existingExdateSet[e.UnixNano()] { + wb.cancelExdates = append(wb.cancelExdates, e) + } + } + for _, e := range existingExdates { + if !writeExdateSet[e.UnixNano()] { + return nil, ErrLinkedCalendarWriteBackRevertUnsupported + } + } + + return wb, nil +} diff --git a/server/internal/service/event_writeback_test.go b/server/internal/service/event_writeback_test.go index f7615a3..4f61b05 100644 --- a/server/internal/service/event_writeback_test.go +++ b/server/internal/service/event_writeback_test.go @@ -380,7 +380,7 @@ func TestEventService_UpsertSeries_NeverWritesProviderOwnedFieldsWhenNotProvider return err } write := SeriesWrite{Title: "Renamed only", Start: event.Start, End: event.End} - return g.Events.upsertSeries(ctx, repos, calendarID, event.ID, userID, userID, seq, write, false, false) + return g.Events.upsertSeries(ctx, repos, calendarID, event.ID, userID, userID, seq, write, false, false, nil) }) if err != nil { t.Fatalf("upsert series: %v", err) @@ -537,7 +537,7 @@ func TestEventService_UpsertSeries_NeverWritesProviderOwnedFieldsOnOverrideWhenN {RecurrenceID: recurrenceID, Title: "Standup (renamed only)", Start: overrideStart, End: overrideStart.Add(30 * time.Minute)}, }, } - return g.Events.upsertSeries(ctx, repos, calendarID, masterID, userID, userID, seq, write, false, false) + return g.Events.upsertSeries(ctx, repos, calendarID, masterID, userID, userID, seq, write, false, false, nil) }) if err != nil { t.Fatalf("upsert series: %v", err) @@ -564,18 +564,18 @@ func TestEventService_UpsertSeries_NeverWritesProviderOwnedFieldsOnOverrideWhenN } } -// TestEventService_PutSeries_RefusesConnectionCalendarMatchingCalDAVsPosture -// covers the CalDAV write seam directly (#290): a CalDAV PUT onto a Linked -// Calendar is refused independent of Exposure (ADR-0080) or the Source's -// mode, since Write-back (ADR-0075) is the only path meant to carry an edit -// to the Provider — but PutCalendarObject itself resolves calendarID -// straight off the request path with no re-check of its own, so a client -// holding a cached or guessed path could otherwise still reach PutSeries -// directly once the Calendar's Source is writable. Expects -// repository.ErrNotFound, matching GetCalendar's own posture (a Calendar -// that "doesn't exist" to CalDAV, not one that exists but refuses the -// write). -func TestEventService_PutSeries_RefusesConnectionCalendarMatchingCalDAVsPosture(t *testing.T) { +// TestEventService_PutSeries_RefusesUnexposedConnectionCalendarMatchingCalDAVsPosture +// covers the CalDAV write seam directly: a CalDAV PUT onto a writable Linked +// Calendar the requesting principal hasn't turned Exposure on for is refused +// exactly as GetCalendar/AccessWithExposure already treat it (ADR-0080) — the +// collection wasn't in their home-set to PUT into in the first place. This +// used to be an unconditional refusal regardless of Exposure (#290); #299/ +// ADR-0081 compiles the PUT into Write-back pushes once Exposure and Access +// both say yes — see event_put_series_writeback_test.go for that path. +// Expects repository.ErrNotFound, matching GetCalendar's own posture (a +// Calendar that "doesn't exist" to CalDAV, not one that exists but refuses +// the write). +func TestEventService_PutSeries_RefusesUnexposedConnectionCalendarMatchingCalDAVsPosture(t *testing.T) { g := newTestGraph(t) userID, calendarID := newTestLinkedCalendar(t, g, repository.SourceModeWritable) diff --git a/server/internal/service/graph.go b/server/internal/service/graph.go index aeed1f3..0b85ac0 100644 --- a/server/internal/service/graph.go +++ b/server/internal/service/graph.go @@ -262,7 +262,7 @@ func NewGraph(sqlDB *sql.DB, cfg config.Config, opts ...GraphOption) (*Graph, er if built.googleEventsURL != nil { connectionOpts = append(connectionOpts, withGoogleEventsURL(built.googleEventsURL.eventsURL)) } - g.Connections = NewConnectionService(g.ConnectionRepo, g.Auth, g.Calendars, g.Events, cfg.GoogleClientID, cfg.GoogleClientSecret, cfg.ConnectionsEncryptionKey, cfg.GoogleConfigured(), connectionOpts...) + g.Connections = NewConnectionService(g.ConnectionRepo, g.Auth, g.Calendars, g.Events, g.NotificationRepo, cfg.GoogleClientID, cfg.GoogleClientSecret, cfg.ConnectionsEncryptionKey, cfg.GoogleConfigured(), connectionOpts...) return g, nil } diff --git a/src/components/layout/NotificationBell.tsx b/src/components/layout/NotificationBell.tsx index 66347cf..9f683c6 100644 --- a/src/components/layout/NotificationBell.tsx +++ b/src/components/layout/NotificationBell.tsx @@ -1,6 +1,6 @@ import { Popover } from "@base-ui/react/popover"; import { formatDistanceToNow } from "date-fns"; -import { Bell, UserPlus } from "lucide-react"; +import { AlertTriangle, Bell, UserPlus } from "lucide-react"; import { useEffect, useState } from "react"; import { useAuthStore } from "../../lib/authStore"; import type { Notification as AppNotification } from "../../lib/notification"; @@ -37,7 +37,11 @@ export function NotificationBell() { } function handleNotificationClick(notification: AppNotification) { - requestEventOpen(notification.eventId); + // A writeback_failed Notification concerns a Calendar, not an Event — + // there's nothing to open (ADR-0081). It still dismisses the popover. + if (notification.kind !== "writeback_failed") { + requestEventOpen(notification.eventId); + } setOpen(false); } @@ -73,6 +77,9 @@ export function NotificationBell() { {notification.kind === "invite" && ( )} + {notification.kind === "writeback_failed" && ( + + )} {notification.title} @@ -80,7 +87,9 @@ export function NotificationBell() {

{notification.kind === "invite" ? "You were invited" - : formatDateTime(notification.occurrenceStart, timeFormat)}{" "} + : notification.kind === "writeback_failed" + ? "Couldn't sync to Google" + : formatDateTime(notification.occurrenceStart, timeFormat)}{" "} ·{" "} {formatDistanceToNow(notification.firedAt, { addSuffix: true, diff --git a/src/lib/notification.ts b/src/lib/notification.ts index ed110f1..f0b4f0f 100644 --- a/src/lib/notification.ts +++ b/src/lib/notification.ts @@ -1,21 +1,25 @@ -// What produced a Notification (ADR-0061): a fired Notification-Channel -// Reminder, or being made an Attendee of an Event. -export type NotificationKind = "reminder" | "invite"; +// What produced a Notification (ADR-0061, ADR-0081): a fired +// Notification-Channel Reminder, being made an Attendee of an Event, or a +// Write-back push permanently failing on a writable Linked Calendar. +export type NotificationKind = "reminder" | "invite" | "writeback_failed"; interface NotificationBase { id: number; - eventId: string; title: string; firedAt: Date; seen: boolean; } // The persistent in-app record of something that concerns one User -// (ADR-0021, ADR-0061, CONTEXT.md). Distinct from a toast, which is a -// transient client-only UI message. A discriminated union on kind, rather -// than an always-present nullable occurrenceStart, so a reminder's start is -// known non-null wherever kind has already been checked — an invite -// concerns the Event series rather than one Occurrence, so it has none. +// (ADR-0021, ADR-0061, ADR-0081, CONTEXT.md). Distinct from a toast, which +// is a transient client-only UI message. A discriminated union on kind, +// rather than always-present nullable eventId/calendarId/occurrenceStart, +// so each variant's own fields are known non-null wherever kind has already +// been checked — reminder and invite concern one Event (an invite the whole +// series, so it has no occurrenceStart of its own), while writeback_failed +// concerns a Calendar instead (coalesced there, not on any one Event) and +// has neither. export type Notification = - | (NotificationBase & { kind: "reminder"; occurrenceStart: Date }) - | (NotificationBase & { kind: "invite"; occurrenceStart: null }); + | (NotificationBase & { kind: "reminder"; eventId: string; occurrenceStart: Date }) + | (NotificationBase & { kind: "invite"; eventId: string; occurrenceStart: null }) + | (NotificationBase & { kind: "writeback_failed"; calendarId: string; occurrenceStart: null }); diff --git a/src/lib/notificationsApi.ts b/src/lib/notificationsApi.ts index 7666ac3..577bbb8 100644 --- a/src/lib/notificationsApi.ts +++ b/src/lib/notificationsApi.ts @@ -3,7 +3,8 @@ import type { Notification, NotificationKind } from "./notification"; interface NotificationWire { id: number; - eventId: string; + eventId?: string; + calendarId?: string; kind: NotificationKind; title: string; occurrenceStart: string | null; @@ -14,19 +15,24 @@ interface NotificationWire { function fromWire(wire: NotificationWire): Notification { const base = { id: wire.id, - eventId: wire.eventId, title: wire.title, firedAt: new Date(wire.firedAt), seen: wire.seen, }; + if (wire.kind === "writeback_failed") { + // calendarId is always set for this kind (server invariant, ADR-0081) — + // the one non-null assertion at this wire boundary is what lets every + // other reader rely on the type instead of re-checking. + return { ...base, kind: "writeback_failed", calendarId: wire.calendarId!, occurrenceStart: null }; + } if (wire.kind === "invite") { - return { ...base, kind: "invite", occurrenceStart: null }; + return { ...base, kind: "invite", eventId: wire.eventId!, occurrenceStart: null }; } // occurrenceStart is always set for a reminder Notification (server // invariant) — the one non-null assertion at this wire boundary is what // lets every other reader rely on the type instead of re-checking. - return { ...base, kind: "reminder", occurrenceStart: new Date(wire.occurrenceStart!) }; + return { ...base, kind: "reminder", eventId: wire.eventId!, occurrenceStart: new Date(wire.occurrenceStart!) }; } export const notificationsApi = { diff --git a/src/lib/notificationsStore.ts b/src/lib/notificationsStore.ts index 9c23fef..6061a8a 100644 --- a/src/lib/notificationsStore.ts +++ b/src/lib/notificationsStore.ts @@ -30,10 +30,14 @@ function showBrowserNotification(notification: AppNotification) { if (typeof Notification === "undefined") return; const timeFormat = useAuthStore.getState().user?.timeFormat ?? "24h"; - const body = - notification.kind === "invite" - ? "You were invited." - : `Starts ${formatDateTime(notification.occurrenceStart, timeFormat)}`; + let body: string; + if (notification.kind === "invite") { + body = "You were invited."; + } else if (notification.kind === "writeback_failed") { + body = "A change couldn't be sent to Google."; + } else { + body = `Starts ${formatDateTime(notification.occurrenceStart, timeFormat)}`; + } const fire = () => { new Notification(notification.title, { body }); From 08f4190a514c56d4f8fc9557e288ceff15ad8e47 Mon Sep 17 00:00:00 2001 From: XiovV Date: Fri, 11 Sep 2026 11:03:10 +0200 Subject: [PATCH 05/33] Calendar Sets exist: create, rename and delete them in Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Calendar Set (ADR-0082) is a named, private selection of a Workspace's Calendars belonging to one User. This delivers the Set itself, CRUD only — membership, the top-bar switcher and the narrowing rule are later tickets. Backend: calendar_sets/calendar_set_members tables (shaped after groups verbatim), CalendarSetRepository/Service registered in the object graph, and /api/calendar-sets routes gated like Groups. Every route resolves a Set by (id, user, active workspace) together and 404s on any mismatch, since a Set is private outright with no Role and no sharing mechanism. Frontend: a Calendar sets Section in Settings' Personal group, mirroring GroupsSection's inline create/rename and delete-confirmation shape, scoped to and re-rendered on the active Workspace. Fixes #301 --- CONTEXT.md | 14 +- ...calendar-sets-are-a-private-view-filter.md | 49 +++ server/cmd/server/main.go | 2 +- server/internal/app/app.go | 2 + .../db/migrations/00009_calendar_sets.sql | 40 +++ server/internal/handlers/calendar_set.go | 126 ++++++++ server/internal/handlers/calendar_set_test.go | 281 ++++++++++++++++++ server/internal/repository/calendar_set.go | 119 ++++++++ server/internal/router/router.go | 20 +- server/internal/router/router_test.go | 2 +- server/internal/service/calendar_set.go | 77 +++++ server/internal/service/graph.go | 4 + src/lib/calendarSetsApi.ts | 63 ++++ src/lib/calendarSetsStore.ts | 45 +++ src/settings/CalendarSetsSection.tsx | 158 ++++++++++ src/settings/DeleteCalendarSetDialog.tsx | 62 ++++ src/settings/settingsSections.tsx | 9 + 17 files changed, 1068 insertions(+), 5 deletions(-) create mode 100644 docs/adr/0082-calendar-sets-are-a-private-view-filter.md create mode 100644 server/internal/db/migrations/00009_calendar_sets.sql create mode 100644 server/internal/handlers/calendar_set.go create mode 100644 server/internal/handlers/calendar_set_test.go create mode 100644 server/internal/repository/calendar_set.go create mode 100644 server/internal/service/calendar_set.go create mode 100644 src/lib/calendarSetsApi.ts create mode 100644 src/lib/calendarSetsStore.ts create mode 100644 src/settings/CalendarSetsSection.tsx create mode 100644 src/settings/DeleteCalendarSetDialog.tsx diff --git a/CONTEXT.md b/CONTEXT.md index f164763..ac6117a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -25,7 +25,7 @@ One of the eight curated colors the web app offers as one-click quick picks when _Avoid_: palette color, preset, theme color **Calendar toggle**: -The sidebar control that shows or hides a Calendar's Occurrences on the grid, rendered in that Calendar's own resolved color rather than a theme accent: filled with a contrasting check when showing, an unfilled ring in the same color when hidden, so color identity survives hiding a Calendar. A sidebar-specific control, not a mode of the app's generic checkbox — its color is data, not a theme constant, and a Calendar whose color matches no Swatch still needs it to render that color. +The sidebar control that shows or hides a Calendar's Occurrences on the grid, rendered in that Calendar's own resolved color rather than a theme accent: filled with a contrasting check when showing, an unfilled ring in the same color when hidden, so color identity survives hiding a Calendar. A sidebar-specific control, not a mode of the app's generic checkbox — its color is data, not a theme constant, and a Calendar whose color matches no Swatch still needs it to render that color. Answers a different question from the Active Calendar Set, which decides whether a Calendar has a row here at all: a Calendar outside the Active Calendar Set is absent rather than unhidden, and its toggle keeps whatever value it had when it returns. _Avoid_: swatch, checkbox (in prose, for this control specifically), dot (the coloured dot it replaced) **Event color**: @@ -138,7 +138,7 @@ The user's chosen appearance — Light, Dark, or System — persisted locally an _Avoid_: color scheme, mode, dark-mode toggle **Settings**: -The overlay where a User adjusts everything that isn't a Calendar or an Event — their Preferences, their Account, their Reminder delivery, their app passwords, their Connections, import and export, and (for the Workspaces they administer) its Members and Groups. Opens *over* the calendar rather than replacing it: the grid stays where it was, and closing returns the User to exactly the view they left. Divided into a **Personal** group, whose Sections concern the one User, and a **Workspace** group, whose Sections concern the active Workspace and everyone in it. Each Section is individually addressable, so one can be linked to and survives a reload. See ADR-0049. +The overlay where a User adjusts everything that isn't a Calendar or an Event — their Preferences, their Account, their Reminder delivery, their app passwords, their Connections, their Calendar Sets, import and export, and (for the Workspaces they administer) its Members and Groups. Opens *over* the calendar rather than replacing it: the grid stays where it was, and closing returns the User to exactly the view they left. Divided into a **Personal** group, whose Sections concern the one User, and a **Workspace** group, whose Sections concern the active Workspace and everyone in it. Each Section is individually addressable, so one can be linked to and survives a reload. See ADR-0049. _Avoid_: settings page, preferences (that's the narrower concept below), config screen **Section**: @@ -347,6 +347,16 @@ _Avoid_: conference link, meeting link, join URL On a Linked Calendar's Event, a bare number of the Provider's attendees who are neither the connecting User nor a resource (a meeting room, say) — conferring nothing, never Attendee rows, so a bare time block that is secretly a fourteen-person meeting is never silently misleading. Distinct from an Event's Attendees, which this app writes its own Invitations against. See ADR-0052. _Avoid_: attendee count (that's an ordinary Event's own Attendee tally), guests, invitee count +## Calendar sets + +**Calendar Set**: +A named, private selection of a Workspace's Calendars belonging to one User — a saved filter, never a container. A Calendar may be in many Sets or in none, and being in none is the ordinary case; Sets may overlap freely, need not cover every Calendar, and deleting one destroys nothing. Its members are drawn from one Workspace, so Calich-native, Subscribed and Linked Calendars sit side by side in one Set — all three are ordinary Calendars carrying a Workspace, and a Set is indifferent to which kind it holds. Private outright, like a Reminder or an Exposure: it is never shared, never granted, and has no Role, which is what separates it from a Group — a Group is a named set of *Members*, this is a named set of *Calendars*. See ADR-0082. +_Avoid_: calendar group (Group is a set of Members), collection, folder, profile, view, filter (it is one, but the word is too general to name the entity) + +**Active Calendar Set**: +The Calendar Set a Session is currently looking through, or none — the default, shown as "All calendars". Narrows *what exists* in the UI rather than what is switched on: a Calendar outside it is absent from the sidebar and from the Event modal's Calendar picker, not unchecked. Leaves Calendar toggle alone, which stays one global answer per Calendar, so the grid renders what is both in the Active Calendar Set and toggled on. Session state in the same sense Active view is, but with no Preference seeding it: every load and every Workspace switch returns to "All calendars", deliberately and not as a memory of where the User last was. Invisible below the UI — Exposure still decides CalDAV home-set membership, and Reminders, Notifications and Write-back are all indifferent to what their User happens to be looking through today. See ADR-0082. +_Avoid_: current set, selected set, set filter, scope + ## Deployment **Data directory**: diff --git a/docs/adr/0082-calendar-sets-are-a-private-view-filter.md b/docs/adr/0082-calendar-sets-are-a-private-view-filter.md new file mode 100644 index 0000000..0df09e0 --- /dev/null +++ b/docs/adr/0082-calendar-sets-are-a-private-view-filter.md @@ -0,0 +1,49 @@ +# A Calendar Set is a private per-User view filter, not a bulk toggle + +Status: accepted — #300 + +A Calendar Set is a named, private selection of one Workspace's Calendars belonging to one User (`calendar_sets` + `calendar_set_members`, shaped after `groups` + `group_members`). Selecting one narrows **what exists** in the UI — a Calendar outside the Active Calendar Set is absent from the sidebar and from the Event modal's Calendar picker — while leaving Calendar toggle alone as one global answer per Calendar. The grid renders what is both in the Active Calendar Set and toggled on. "All calendars" is the absence of a Set, not a row. + +The obvious cheaper design is the one this rejects: have selecting a Set write `checkedCalendarIds`. That needs no schema at read time, no new filter in `CalendarList`, and no fourth `CalendarPickerEmptyReason` — which is exactly why a future reader will assume it was overlooked rather than refused. + +## Why not drive the toggles + +Because it creates a dirty state with no honest rendering. Select "Work", then toggle one Calendar: the visible set no longer matches the named Set, and the switcher must either lie (still say "Work"), invent a modified state ("Work\*") with rules about when it resolves, or silently rewrite the Set from a gesture the User made about *today's view*. Every one of those is worse than the thing it saves. + +Keeping the two orthogonal removes the question entirely. The Set answers "which Calendars exist right now"; the toggle answers "of those, which are showing". Neither can put the other into a state that needs explaining, `shellStore` gains no new state at all, and switching Sets is navigation rather than mutation — re-entering a Set never wipes a deliberate uncheck. + +## The Event picker narrows too, and that is the load-bearing part + +`src/lib/calendar.ts` already restricts the Event modal's Calendar picker to *checked* Calendars, and `CalendarPickerEmptyReason`'s `"hidden"` case exists solely to say "you own a Calendar but it's unchecked — go check it". The invariant underneath is **you can only create an Event on a Calendar you can currently see.** + +Because the Active Calendar Set is a second way of not seeing a Calendar, it must narrow the picker too, or that invariant acquires an exception nobody can derive from first principles. `CalendarPickerEmptyReason` therefore gains `"outOfSet"`, which fires only when the Active Calendar Set is itself empty; inside a non-empty Set the existing `hidden` / `unwritable` precedence applies unchanged, over the Set's members instead of over every Calendar. Its remedy is "switch to All calendars", exactly parallel to `hidden`'s "check it". + +Leaving the picker unfiltered was considered and is genuinely cheaper — it sources from `checkedCalendarIds`, which stays global, so it would need no code at all. It was refused because it would offer Calendars the sidebar is deliberately hiding, and produce an Event that vanishes the instant it is saved. + +## Decision + +- `calendar_sets(id, user_id, workspace_id, name, created_at)` and `calendar_set_members(set_id, calendar_id)` (#300), mirroring `groups`/`group_members` verbatim — no name-uniqueness constraint, `ORDER BY created_at, id`. A new append-only migration, per ADR-0048's rule that the squash is an occasional deliberate act rather than something every schema change performs. +- **Scoped to `(user_id, workspace_id)`.** A Calendar belongs to exactly one Workspace — a Linked Calendar included, since `ConnectionService.ImportCalendars` creates it *into* one, and the same Provider calendar imported into two Workspaces is two Calendar rows. A Set spanning Workspaces would surface Calendars from a Workspace the User is not currently in, contradicting ADR-0044's Workspace-as-visibility-boundary. +- **Private, with no sharing mechanism and no Role.** Not "private for now": there is no owner-vs-grantee axis to resolve, so the read path is `WHERE user_id = ?` and the question "can B see A's Set?" does not exist. Sitting in Settings' **Personal** group is the position that says so, alongside Preferences and Reminder delivery rather than beside Admin-managed Members and Groups. +- **Membership cascades on `calendars`.** A revoked Share, an unsubscribe, or a disconnected Connection silently shrinks any Set the Calendar was in, with no reconciliation code and no notification. An emptied Set survives; it does not self-delete. +- **An empty Set is legal**, whether created empty or emptied by cascade. Forbidding the first would not prevent the second, so the empty state has to exist regardless and the restriction would buy only an inconsistency. +- **Session state, with no Preference behind it.** The Active Calendar Set resets to "All calendars" on every load and on every Workspace switch. Deliberately unlike Default view (ADR-0039), whose per-User column would here point at a Set belonging to one Workspace and be meaningless in the others; the per-`(User, Workspace)` default this would need — an `is_default` flag, or a column on `workspace_members` — was designed and then dropped as unearned for a first cut. +- **Membership is curated by hand.** Creating a Calendar, subscribing to a feed, or bulk-importing from a Connection does not auto-join the Active Calendar Set. The three creation dialogs instead carry an **unticked** "Add to *N*" checkbox, present only while a Set is active. +- **No effect below the UI.** CalDAV home-set membership stays Exposure's (ADR-0080); Reminders and Notifications stay personal and server-resolved; Write-back is untouched. A Set is a property of what one browser tab is looking through, and nothing that runs without a tab open may consult it. +- **Sidebar grouping survives, filtered.** The My calendars / Subscribed / per-Connection headings stay, each showing only its in-Set Calendars, with an emptied heading hidden — one filter on `CalendarList`'s input rather than a second layout. +- Surfaces: a Settings Section mirroring `GroupsSection` (inline create, rename-in-place, `DeleteCalendarSetDialog`, `CalendarSetCalendarsDialog`); a top-bar switcher, always visible even at zero Sets, built on `Menu` rather than `WorkspaceSwitcher`'s `Select` so it can carry a divider and a "Manage sets…" deep link (ADR-0049 makes Settings route-addressable); and an "Add to set" submenu on each sidebar row's existing action menu. + +## Considered Options + +- **A private per-User saved filter that narrows what exists (chosen).** +- **Selecting a Set writes `checkedCalendarIds`.** Cheapest by a wide margin and needs no schema on the read path, but produces the dirty state above and makes re-entering a Set destroy a deliberate uncheck. +- **A Set as a container — each Calendar in exactly one, plus an implicit "Ungrouped".** Folder-shaped and familiar, but the motivating use case is a Calendar that belongs to both "Work" and "Personal", which a partition cannot express. +- **Workspace-owned, Admin-managed Sets, shaped like Group.** Would let a Workspace ship a curated set to its Members, but the same Set renders differently for every Member depending on their Access and may render empty, and the feature asked for was a personal one. + +## Consequences + +- **`shellStore` gains no state.** `checkedCalendarIds` and `knownCalendarIds` keep their exact meaning and their #116 reconcile behaviour; the Active Calendar Set is a separate, simpler value that never writes to them. +- **A Calendar created while a Set is active does not appear**, and the unticked checkbox is the only thing that explains why. This is the design's weakest seam, accepted knowingly: the alternative — auto-joining, by analogy with #175's auto-check — makes the Set's membership a side effect of unrelated actions, which is the property the hand-curation rule exists to prevent. +- **`calendarPickerEmptyReason` becomes Set-aware**, so its callers must pass the Active Calendar Set's members, not the full Calendar list. Its three existing cases keep their exact meanings, now evaluated over a narrower input. +- **Calendar toggle's glossary entry gains a boundary clause**, because "hidden" and "not in the Active Calendar Set" are now two different ways a Calendar fails to appear and the sidebar renders them differently — one as an unfilled ring, the other as no row. +- **Settings' Personal group acquires its first workspace-dependent Section.** Every other Personal Section's contents are stable across a Workspace switch; this one re-renders. Accepted because privacy, not workspace-independence, is what the Personal grouping communicates. diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index ab22fe9..7fb7fdf 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -87,7 +87,7 @@ func main() { } } - handler, err := router.New(logger, a.AuthHandler, a.CalendarHandler, a.EventHandler, a.AttachmentHandler, a.NotificationHandler, a.AppPasswordHandler, a.AccountHandler, a.UserHandler, a.WorkspaceHandler, a.GroupHandler, a.ConnectionHandler, a.CalDAVHandler, a.Auth, a.Auth, a.AppPasswords, a.RateLimiter, a.Auth, a.Workspaces) + handler, err := router.New(logger, a.AuthHandler, a.CalendarHandler, a.EventHandler, a.AttachmentHandler, a.NotificationHandler, a.AppPasswordHandler, a.AccountHandler, a.UserHandler, a.WorkspaceHandler, a.GroupHandler, a.CalendarSetHandler, a.ConnectionHandler, a.CalDAVHandler, a.Auth, a.Auth, a.AppPasswords, a.RateLimiter, a.Auth, a.Workspaces) if err != nil { logger.Error("failed to build router", "error", err) os.Exit(1) diff --git a/server/internal/app/app.go b/server/internal/app/app.go index d95d52a..8dc0732 100644 --- a/server/internal/app/app.go +++ b/server/internal/app/app.go @@ -56,6 +56,7 @@ type App struct { UserHandler *handlers.UserHandler WorkspaceHandler *handlers.WorkspaceHandler GroupHandler *handlers.GroupHandler + CalendarSetHandler *handlers.CalendarSetHandler ConnectionHandler *handlers.ConnectionHandler CalDAVBackend *caldavserver.Backend @@ -118,6 +119,7 @@ func newFromGraph(graph *service.Graph, cfg config.Config) *App { a.UserHandler = handlers.NewUserHandler(a.Users) a.WorkspaceHandler = handlers.NewWorkspaceHandler(a.Workspaces) a.GroupHandler = handlers.NewGroupHandler(a.Groups) + a.CalendarSetHandler = handlers.NewCalendarSetHandler(a.CalendarSets) a.ConnectionHandler = handlers.NewConnectionHandler(a.Connections) a.CalDAVBackend = caldavserver.NewBackend(a.Calendars, a.Events, a.Attachments, cfg.MaxAttachmentSize, cfg.MaxAttachmentsPerEvent) diff --git a/server/internal/db/migrations/00009_calendar_sets.sql b/server/internal/db/migrations/00009_calendar_sets.sql new file mode 100644 index 0000000..ecc4946 --- /dev/null +++ b/server/internal/db/migrations/00009_calendar_sets.sql @@ -0,0 +1,40 @@ +-- +goose Up +-- calendar_sets and calendar_set_members are a Calendar Set (#301, ADR-0082, +-- CONTEXT.md's Calendar sets entry): a named, private selection of one +-- Workspace's Calendars belonging to one User. Shaped after groups and +-- group_members verbatim (ADR-0048's append-only era), with a second scoping +-- column groups doesn't need: a Group belongs to a Workspace alone, while a +-- Calendar Set is private to one User within one Workspace, so both +-- user_id and workspace_id are carried the way calendars itself carries +-- both. No name-uniqueness constraint, ordered by created_at then id. +-- +-- A Set is private outright — no sharing mechanism, no Role, no owner-vs- +-- grantee axis — so the read path is WHERE user_id = ? AND workspace_id = ? +-- and "can B see A's Set?" is not a question the code can ask. +-- +-- calendar_set_members cascades on calendars: a revoked Share, an +-- unsubscribe, a Calendar deletion or a disconnected Connection shrinks any +-- Set the Calendar was in, with no reconciliation code and no notification. +-- An emptied Set survives. +CREATE TABLE calendar_sets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + name TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_calendar_sets_user_id ON calendar_sets(user_id); +CREATE INDEX idx_calendar_sets_workspace_id ON calendar_sets(workspace_id); + +CREATE TABLE calendar_set_members ( + set_id INTEGER NOT NULL REFERENCES calendar_sets(id) ON DELETE CASCADE, + calendar_id TEXT NOT NULL REFERENCES calendars(id) ON DELETE CASCADE, + PRIMARY KEY (set_id, calendar_id) +); + +CREATE INDEX idx_calendar_set_members_calendar_id ON calendar_set_members(calendar_id); + +-- +goose Down +DROP TABLE calendar_set_members; +DROP TABLE calendar_sets; diff --git a/server/internal/handlers/calendar_set.go b/server/internal/handlers/calendar_set.go new file mode 100644 index 0000000..c225022 --- /dev/null +++ b/server/internal/handlers/calendar_set.go @@ -0,0 +1,126 @@ +// calendar_set.go implements CalendarSetHandler: Create/List/Rename/Delete +// back the Calendar Sets Settings Section (#301, ADR-0082). A Calendar Set +// is private outright, so every route resolves by the caller's own userID +// and the active workspaceID together and renders a mismatch as 404 — a Set +// belonging to someone else is indistinguishable from one that does not +// exist. +package handlers + +import ( + "net/http" + + "github.com/XiovV/calich/server/internal/httpauth" + "github.com/XiovV/calich/server/internal/httpresponse" + "github.com/XiovV/calich/server/internal/repository" + "github.com/XiovV/calich/server/internal/service" +) + +type CalendarSetHandler struct { + sets *service.CalendarSetService +} + +func NewCalendarSetHandler(sets *service.CalendarSetService) *CalendarSetHandler { + return &CalendarSetHandler{sets: sets} +} + +type calendarSetResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +func toCalendarSetResponse(s repository.CalendarSet) calendarSetResponse { + return calendarSetResponse{ID: s.ID, Name: s.Name} +} + +// calendarSetErrors renders the sentinels every CalendarSetService call can +// return: an empty name, and repository.ErrNotFound for a Set that doesn't +// exist, belongs to another User, or belongs to another Workspace — the +// three are indistinguishable by design. +var calendarSetErrors = []errorCase{ + {service.ErrInvalidCalendarSetName, badRequest(service.ErrInvalidCalendarSetName.Error())}, + {repository.ErrNotFound, notFound("calendar set not found")}, +} + +// List serves GET /api/calendar-sets: every Calendar Set the caller owns in +// their active Workspace. +func (h *CalendarSetHandler) List(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + + sets, err := h.sets.ListForUser(r.Context(), userID, workspaceID) + if respondError(w, err, calendarSetErrors, "failed to list calendar sets") { + return + } + + response := make([]calendarSetResponse, len(sets)) + for i, s := range sets { + response[i] = toCalendarSetResponse(s) + } + + httpresponse.JSON(w, http.StatusOK, response) +} + +type createCalendarSetRequest struct { + Name string `json:"name"` +} + +// Create makes a new Calendar Set in the caller's active Workspace, named +// only — membership is added separately. +func (h *CalendarSetHandler) Create(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + + req, ok := decodeJSON[createCalendarSetRequest](w, r) + if !ok { + return + } + + set, err := h.sets.Create(r.Context(), userID, workspaceID, req.Name) + if respondError(w, err, calendarSetErrors, "failed to create calendar set") { + return + } + + httpresponse.JSON(w, http.StatusCreated, toCalendarSetResponse(set)) +} + +type renameCalendarSetRequest struct { + Name string `json:"name"` +} + +// Rename changes a Calendar Set's name. +func (h *CalendarSetHandler) Rename(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + id, ok := parseInt64Param(w, r, "id") + if !ok { + return + } + + req, ok := decodeJSON[renameCalendarSetRequest](w, r) + if !ok { + return + } + + set, err := h.sets.Rename(r.Context(), userID, workspaceID, id, req.Name) + if respondError(w, err, calendarSetErrors, "failed to rename calendar set") { + return + } + + httpresponse.JSON(w, http.StatusOK, toCalendarSetResponse(set)) +} + +// Delete removes a Calendar Set outright. Destroys no Calendar and no Event. +func (h *CalendarSetHandler) Delete(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + id, ok := parseInt64Param(w, r, "id") + if !ok { + return + } + + if err := h.sets.Delete(r.Context(), userID, workspaceID, id); respondError(w, err, calendarSetErrors, "failed to delete calendar set") { + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/internal/handlers/calendar_set_test.go b/server/internal/handlers/calendar_set_test.go new file mode 100644 index 0000000..3a03f94 --- /dev/null +++ b/server/internal/handlers/calendar_set_test.go @@ -0,0 +1,281 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/XiovV/calich/server/internal/apptest" + "github.com/XiovV/calich/server/internal/httpauth" + "github.com/XiovV/calich/server/internal/service" +) + +// calendarSetHandlerTestServer bundles the HTTP surface the Calendar Sets +// Settings Section needs (#301, ADR-0082): Register (to mint real Users, +// each with their own Workspace) and the /api/calendar-sets routes, gated by +// RequireWorkspace exactly like router.New wires them. +type calendarSetHandlerTestServer struct { + srv *httptest.Server + graph *service.Graph + workspaces *service.WorkspaceService +} + +func newCalendarSetHandlerTestServer(t *testing.T) *calendarSetHandlerTestServer { + t.Helper() + + // No bootstrap account, and ENABLE_SIGNUPS on: every User here registers + // through the HTTP surface. + cfg := apptest.Config(t) + cfg.InitialName, cfg.InitialEmail, cfg.InitialPassword = "", "", "" + cfg.EnableSignups = true + g := newTestGraphWithConfig(t, cfg) + + workspaces := g.Workspaces + auth := g.Auth + sets := g.CalendarSets + + authHandler := NewAuthHandler(auth, g.RateLimiter, false, false, false, true) + calendarSetHandler := NewCalendarSetHandler(sets) + + r := chi.NewRouter() + r.Post("/api/auth/register", authHandler.Register) + r.Route("/api/calendar-sets", func(r chi.Router) { + r.Use(httpauth.RequireAuth(auth)) + r.Use(httpauth.RequireEnabledUser(auth)) + r.Use(httpauth.RequireWorkspace(workspaces)) + + r.Get("/", calendarSetHandler.List) + r.Post("/", calendarSetHandler.Create) + r.Patch("/{id}", calendarSetHandler.Rename) + r.Delete("/{id}", calendarSetHandler.Delete) + }) + + srv := httptest.NewServer(r) + t.Cleanup(srv.Close) + + return &calendarSetHandlerTestServer{srv: srv, graph: g, workspaces: workspaces} +} + +func (s *calendarSetHandlerTestServer) register(t *testing.T, username string) (accessToken string, userID, workspaceID int64) { + t.Helper() + ctx := context.Background() + + body, err := json.Marshal(registerRequest{Name: username, Email: username + "@example.com", Password: "hunter22"}) + if err != nil { + t.Fatalf("marshal register request: %v", err) + } + resp, err := http.Post(s.srv.URL+"/api/auth/register", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("POST /api/auth/register: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 registering %s, got %d", username, resp.StatusCode) + } + var logged loginResponse + if err := json.NewDecoder(resp.Body).Decode(&logged); err != nil { + t.Fatalf("decode register response: %v", err) + } + + users := s.graph.UserRepo + user, err := users.GetByEmail(ctx, username+"@example.com") + if err != nil { + t.Fatalf("get %s: %v", username, err) + } + workspaces, err := s.workspaces.ListForUser(ctx, user.ID) + if err != nil || len(workspaces) != 1 { + t.Fatalf("list workspaces for %s: %v (%d)", username, err, len(workspaces)) + } + + return logged.AccessToken, user.ID, workspaces[0].ID +} + +func (s *calendarSetHandlerTestServer) do(t *testing.T, method, path, accessToken string, workspaceID int64, body any) *http.Response { + t.Helper() + + var reader *bytes.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + reader = bytes.NewReader(b) + } else { + reader = bytes.NewReader(nil) + } + + req, err := http.NewRequest(method, s.srv.URL+path, reader) + if err != nil { + t.Fatalf("build request: %v", err) + } + if accessToken != "" { + req.Header.Set("Authorization", "Bearer "+accessToken) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Workspace-Id", strconv.FormatInt(workspaceID, 10)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + return resp +} + +func TestCalendarSetHandler_Create_AnyMemberCanCreate(t *testing.T) { + s := newCalendarSetHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + + resp := s.do(t, http.MethodPost, "/api/calendar-sets/", token, workspaceID, createCalendarSetRequest{Name: "Work"}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201, got %d", resp.StatusCode) + } + + var set calendarSetResponse + if err := json.NewDecoder(resp.Body).Decode(&set); err != nil { + t.Fatalf("decode response: %v", err) + } + if set.Name != "Work" { + t.Fatalf("expected name %q, got %q", "Work", set.Name) + } +} + +func TestCalendarSetHandler_Create_EmptyNameRejected(t *testing.T) { + s := newCalendarSetHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + + resp := s.do(t, http.MethodPost, "/api/calendar-sets/", token, workspaceID, createCalendarSetRequest{Name: " "}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestCalendarSetHandler_RenameAndDelete_OwnerCanManage(t *testing.T) { + s := newCalendarSetHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + + createResp := s.do(t, http.MethodPost, "/api/calendar-sets/", token, workspaceID, createCalendarSetRequest{Name: "Work"}) + defer createResp.Body.Close() + var set calendarSetResponse + if err := json.NewDecoder(createResp.Body).Decode(&set); err != nil { + t.Fatalf("decode create response: %v", err) + } + + renameResp := s.do(t, http.MethodPatch, "/api/calendar-sets/"+strconv.FormatInt(set.ID, 10), token, workspaceID, renameCalendarSetRequest{Name: "Weekend"}) + defer renameResp.Body.Close() + if renameResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", renameResp.StatusCode) + } + var renamed calendarSetResponse + if err := json.NewDecoder(renameResp.Body).Decode(&renamed); err != nil { + t.Fatalf("decode rename response: %v", err) + } + if renamed.Name != "Weekend" { + t.Fatalf("expected name %q, got %q", "Weekend", renamed.Name) + } + + deleteResp := s.do(t, http.MethodDelete, "/api/calendar-sets/"+strconv.FormatInt(set.ID, 10), token, workspaceID, nil) + defer deleteResp.Body.Close() + if deleteResp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204, got %d", deleteResp.StatusCode) + } + + listResp := s.do(t, http.MethodGet, "/api/calendar-sets/", token, workspaceID, nil) + defer listResp.Body.Close() + var list []calendarSetResponse + if err := json.NewDecoder(listResp.Body).Decode(&list); err != nil { + t.Fatalf("decode list response: %v", err) + } + if len(list) != 0 { + t.Fatalf("expected no calendar sets after delete, got %v", list) + } +} + +func TestCalendarSetHandler_List_ScopedToWorkspace(t *testing.T) { + s := newCalendarSetHandlerTestServer(t) + token, userID, firstWorkspaceID := s.register(t, "alice") + + createResp := s.do(t, http.MethodPost, "/api/calendar-sets/", token, firstWorkspaceID, createCalendarSetRequest{Name: "Work"}) + defer createResp.Body.Close() + if createResp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201, got %d", createResp.StatusCode) + } + + secondWorkspace, err := s.graph.Workspaces.CreateForOwner(context.Background(), userID, "Second workspace") + if err != nil { + t.Fatalf("create second workspace: %v", err) + } + + listResp := s.do(t, http.MethodGet, "/api/calendar-sets/", token, secondWorkspace.ID, nil) + defer listResp.Body.Close() + if listResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", listResp.StatusCode) + } + var list []calendarSetResponse + if err := json.NewDecoder(listResp.Body).Decode(&list); err != nil { + t.Fatalf("decode list response: %v", err) + } + if len(list) != 0 { + t.Fatalf("expected no calendar sets in the second workspace, got %v", list) + } +} + +func TestCalendarSetHandler_SecondUserCannotReadRenameOrDeleteFirstsSet(t *testing.T) { + s := newCalendarSetHandlerTestServer(t) + aliceToken, _, workspaceID := s.register(t, "alice") + bobToken, bobID, _ := s.register(t, "bob") + // bob is not a Member of alice's Workspace, but that's irrelevant here: + // even a Member of the same Workspace must not reach another User's + // private Set, so add bob to alice's Workspace to prove privacy holds + // regardless of shared Workspace membership. + if _, err := s.graph.DB.Exec("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", workspaceID, bobID, "member"); err != nil { + t.Fatalf("add bob to alice's workspace: %v", err) + } + + createResp := s.do(t, http.MethodPost, "/api/calendar-sets/", aliceToken, workspaceID, createCalendarSetRequest{Name: "Work"}) + defer createResp.Body.Close() + var set calendarSetResponse + if err := json.NewDecoder(createResp.Body).Decode(&set); err != nil { + t.Fatalf("decode create response: %v", err) + } + + renameResp := s.do(t, http.MethodPatch, "/api/calendar-sets/"+strconv.FormatInt(set.ID, 10), bobToken, workspaceID, renameCalendarSetRequest{Name: "Hijacked"}) + defer renameResp.Body.Close() + if renameResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 renaming, got %d", renameResp.StatusCode) + } + + deleteResp := s.do(t, http.MethodDelete, "/api/calendar-sets/"+strconv.FormatInt(set.ID, 10), bobToken, workspaceID, nil) + defer deleteResp.Body.Close() + if deleteResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 deleting, got %d", deleteResp.StatusCode) + } + + listResp := s.do(t, http.MethodGet, "/api/calendar-sets/", bobToken, workspaceID, nil) + defer listResp.Body.Close() + var list []calendarSetResponse + if err := json.NewDecoder(listResp.Body).Decode(&list); err != nil { + t.Fatalf("decode list response: %v", err) + } + if len(list) != 0 { + t.Fatalf("expected bob to see no calendar sets, got %v", list) + } + + // alice's Set survived every one of bob's attempts. + aliceListResp := s.do(t, http.MethodGet, "/api/calendar-sets/", aliceToken, workspaceID, nil) + defer aliceListResp.Body.Close() + var aliceList []calendarSetResponse + if err := json.NewDecoder(aliceListResp.Body).Decode(&aliceList); err != nil { + t.Fatalf("decode alice list response: %v", err) + } + if len(aliceList) != 1 || aliceList[0].Name != "Work" { + t.Fatalf("expected alice's set to survive untouched, got %v", aliceList) + } +} diff --git a/server/internal/repository/calendar_set.go b/server/internal/repository/calendar_set.go new file mode 100644 index 0000000..001766e --- /dev/null +++ b/server/internal/repository/calendar_set.go @@ -0,0 +1,119 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// CalendarSet is a Calendar Set (ADR-0082): a named, private selection of +// one Workspace's Calendars belonging to one User. Membership lives in +// calendar_set_members, added separately from creation. +type CalendarSet struct { + ID int64 + UserID int64 + WorkspaceID int64 + Name string + CreatedAt time.Time +} + +type CalendarSetRepository struct { + db DBTX +} + +func NewCalendarSetRepository(db *sql.DB) *CalendarSetRepository { + return &CalendarSetRepository{db: db} +} + +// WithTx returns a copy of the repository bound to tx. +func (r *CalendarSetRepository) WithTx(tx *sql.Tx) *CalendarSetRepository { + return &CalendarSetRepository{db: tx} +} + +// Create inserts a new Calendar Set named name, owned by userID inside +// workspaceID. +func (r *CalendarSetRepository) Create(ctx context.Context, userID, workspaceID int64, name string) (CalendarSet, error) { + res, err := r.db.ExecContext(ctx, + `INSERT INTO calendar_sets (user_id, workspace_id, name) VALUES (?, ?, ?)`, + userID, workspaceID, name, + ) + if err != nil { + return CalendarSet{}, fmt.Errorf("insert calendar set: %w", err) + } + + id, err := res.LastInsertId() + if err != nil { + return CalendarSet{}, fmt.Errorf("get inserted calendar set id: %w", err) + } + + return r.GetByID(ctx, id, userID, workspaceID) +} + +// GetByID returns the Calendar Set identified by id, scoped to userID and +// workspaceID together — a Set belonging to another User, or to a different +// Workspace, is ErrNotFound rather than any error naming the mismatch, so +// the caller can never distinguish "not mine" from "doesn't exist". +func (r *CalendarSetRepository) GetByID(ctx context.Context, id, userID, workspaceID int64) (CalendarSet, error) { + var s CalendarSet + err := r.db.QueryRowContext(ctx, + `SELECT id, user_id, workspace_id, name, created_at FROM calendar_sets WHERE id = ? AND user_id = ? AND workspace_id = ?`, + id, userID, workspaceID, + ).Scan(&s.ID, &s.UserID, &s.WorkspaceID, &s.Name, &s.CreatedAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return CalendarSet{}, ErrNotFound + } + return CalendarSet{}, fmt.Errorf("scan calendar set: %w", err) + } + return s, nil +} + +// ListForUser returns every Calendar Set userID owns inside workspaceID, +// ordered by when it was created. +func (r *CalendarSetRepository) ListForUser(ctx context.Context, userID, workspaceID int64) ([]CalendarSet, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT id, user_id, workspace_id, name, created_at FROM calendar_sets WHERE user_id = ? AND workspace_id = ? ORDER BY created_at, id`, + userID, workspaceID, + ) + if err != nil { + return nil, fmt.Errorf("list calendar sets: %w", err) + } + return collectRows(rows, scanCalendarSetRow) +} + +func scanCalendarSetRow(row rowScanner) (CalendarSet, error) { + var s CalendarSet + err := row.Scan(&s.ID, &s.UserID, &s.WorkspaceID, &s.Name, &s.CreatedAt) + return s, err +} + +// Rename updates id's name, scoped to userID and workspaceID like GetByID. +func (r *CalendarSetRepository) Rename(ctx context.Context, id, userID, workspaceID int64, name string) (CalendarSet, error) { + res, err := r.db.ExecContext(ctx, + `UPDATE calendar_sets SET name = ? WHERE id = ? AND user_id = ? AND workspace_id = ?`, + name, id, userID, workspaceID, + ) + if err != nil { + return CalendarSet{}, fmt.Errorf("rename calendar set: %w", err) + } + if err := requireAffected(res); err != nil { + return CalendarSet{}, err + } + return r.GetByID(ctx, id, userID, workspaceID) +} + +// Delete removes id, scoped to userID and workspaceID like GetByID, and via +// ON DELETE CASCADE every calendar_set_members row that referenced it. It +// destroys no Calendar and no Event. +func (r *CalendarSetRepository) Delete(ctx context.Context, id, userID, workspaceID int64) error { + res, err := r.db.ExecContext(ctx, + `DELETE FROM calendar_sets WHERE id = ? AND user_id = ? AND workspace_id = ?`, + id, userID, workspaceID, + ) + if err != nil { + return fmt.Errorf("delete calendar set: %w", err) + } + return requireAffected(res) +} diff --git a/server/internal/router/router.go b/server/internal/router/router.go index bc3fbe3..615bbba 100644 --- a/server/internal/router/router.go +++ b/server/internal/router/router.go @@ -15,7 +15,7 @@ import ( "github.com/XiovV/calich/server/internal/static" ) -func New(logger *slog.Logger, authHandler *handlers.AuthHandler, calendarHandler *handlers.CalendarHandler, eventHandler *handlers.EventHandler, attachmentHandler *handlers.AttachmentHandler, notificationHandler *handlers.NotificationHandler, appPasswordHandler *handlers.AppPasswordHandler, accountHandler *handlers.AccountHandler, userHandler *handlers.UserHandler, workspaceHandler *handlers.WorkspaceHandler, groupHandler *handlers.GroupHandler, connectionHandler *handlers.ConnectionHandler, calDAVHandler http.Handler, authenticator httpauth.Authenticator, activeUserChecker httpauth.ActiveUserChecker, calDAVAuthenticator httpauth.CalDAVAuthenticator, calDAVRateLimiter httpauth.CalDAVRateLimiter, enabledChecker httpauth.DisabledChecker, workspaceMembershipChecker httpauth.WorkspaceMembershipChecker) (http.Handler, error) { +func New(logger *slog.Logger, authHandler *handlers.AuthHandler, calendarHandler *handlers.CalendarHandler, eventHandler *handlers.EventHandler, attachmentHandler *handlers.AttachmentHandler, notificationHandler *handlers.NotificationHandler, appPasswordHandler *handlers.AppPasswordHandler, accountHandler *handlers.AccountHandler, userHandler *handlers.UserHandler, workspaceHandler *handlers.WorkspaceHandler, groupHandler *handlers.GroupHandler, calendarSetHandler *handlers.CalendarSetHandler, connectionHandler *handlers.ConnectionHandler, calDAVHandler http.Handler, authenticator httpauth.Authenticator, activeUserChecker httpauth.ActiveUserChecker, calDAVAuthenticator httpauth.CalDAVAuthenticator, calDAVRateLimiter httpauth.CalDAVRateLimiter, enabledChecker httpauth.DisabledChecker, workspaceMembershipChecker httpauth.WorkspaceMembershipChecker) (http.Handler, error) { r := chi.NewRouter() r.Use(requestLogger(logger)) r.Use(middleware.Recoverer) @@ -241,6 +241,24 @@ func New(logger *slog.Logger, authHandler *handlers.AuthHandler, calendarHandler r.Delete("/{id}/members/{userId}", groupHandler.RemoveMember) }) + // Calendar Sets (#301, ADR-0082): a named, private selection of the + // caller's active Workspace's Calendars, gated the same as Groups. + // Private outright — CalendarSetService itself resolves every Set by + // (id, caller, active workspace) and refuses with ErrNotFound rather + // than any authority check, so no extra middleware gate is needed + // beyond RequireWorkspace. + r.Route("/calendar-sets", func(r chi.Router) { + r.Use(httpauth.RequireAuth(authenticator)) + r.Use(httpauth.RequireActiveUser(activeUserChecker)) + r.Use(httpauth.RequireEnabledUser(enabledChecker)) + r.Use(httpauth.RequireWorkspace(workspaceMembershipChecker)) + + r.Get("/", calendarSetHandler.List) + r.Post("/", calendarSetHandler.Create) + r.Patch("/{id}", calendarSetHandler.Rename) + r.Delete("/{id}", calendarSetHandler.Delete) + }) + // Connections (#285, ADR-0050, ADR-0051): Callback sits outside // RequireAuth on purpose — Google's redirect back is an // unauthenticated top-level navigation carrying neither an diff --git a/server/internal/router/router_test.go b/server/internal/router/router_test.go index 50588db..511e15f 100644 --- a/server/internal/router/router_test.go +++ b/server/internal/router/router_test.go @@ -29,7 +29,7 @@ func newTestRouter(t *testing.T) http.Handler { t.Fatalf("bootstrap: %v", err) } - r, err := New(slog.New(slog.NewTextHandler(io.Discard, nil)), a.AuthHandler, a.CalendarHandler, a.EventHandler, a.AttachmentHandler, a.NotificationHandler, a.AppPasswordHandler, a.AccountHandler, a.UserHandler, a.WorkspaceHandler, a.GroupHandler, a.ConnectionHandler, a.CalDAVHandler, a.Auth, a.Auth, a.AppPasswords, a.RateLimiter, a.Auth, a.Workspaces) + r, err := New(slog.New(slog.NewTextHandler(io.Discard, nil)), a.AuthHandler, a.CalendarHandler, a.EventHandler, a.AttachmentHandler, a.NotificationHandler, a.AppPasswordHandler, a.AccountHandler, a.UserHandler, a.WorkspaceHandler, a.GroupHandler, a.CalendarSetHandler, a.ConnectionHandler, a.CalDAVHandler, a.Auth, a.Auth, a.AppPasswords, a.RateLimiter, a.Auth, a.Workspaces) if err != nil { t.Fatalf("New: %v", err) } diff --git a/server/internal/service/calendar_set.go b/server/internal/service/calendar_set.go new file mode 100644 index 0000000..a06ac13 --- /dev/null +++ b/server/internal/service/calendar_set.go @@ -0,0 +1,77 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/XiovV/calich/server/internal/repository" +) + +// ErrInvalidCalendarSetName is returned by CalendarSetService.Create and +// Rename when name is empty. +var ErrInvalidCalendarSetName = errors.New("calendar set name must not be empty") + +// CalendarSetService creates, renames, lists and deletes Calendar Sets +// (ADR-0082): a named, private selection of a Workspace's Calendars +// belonging to one User. Private outright — there is no sharing mechanism +// and no Role, so unlike GroupService this performs no +// requireWorkspaceOwnerOrAdmin-style authority check; every method is +// scoped to the caller's own userID and workspaceID by the repository call +// itself, and a Set belonging to someone else surfaces as +// repository.ErrNotFound rather than any distinguishable "forbidden". +type CalendarSetService struct { + sets *repository.CalendarSetRepository +} + +func NewCalendarSetService(sets *repository.CalendarSetRepository) *CalendarSetService { + return &CalendarSetService{sets: sets} +} + +// Create makes a new Calendar Set named name, owned by userID inside +// workspaceID. Membership is added separately. +func (s *CalendarSetService) Create(ctx context.Context, userID, workspaceID int64, name string) (repository.CalendarSet, error) { + name = strings.TrimSpace(name) + if name == "" { + return repository.CalendarSet{}, ErrInvalidCalendarSetName + } + + set, err := s.sets.Create(ctx, userID, workspaceID, name) + if err != nil { + return repository.CalendarSet{}, fmt.Errorf("create calendar set: %w", err) + } + return set, nil +} + +// ListForUser returns every Calendar Set userID owns inside workspaceID. +func (s *CalendarSetService) ListForUser(ctx context.Context, userID, workspaceID int64) ([]repository.CalendarSet, error) { + sets, err := s.sets.ListForUser(ctx, userID, workspaceID) + if err != nil { + return nil, fmt.Errorf("list calendar sets: %w", err) + } + return sets, nil +} + +// Rename changes id's name, scoped to userID and workspaceID. +func (s *CalendarSetService) Rename(ctx context.Context, userID, workspaceID, id int64, name string) (repository.CalendarSet, error) { + name = strings.TrimSpace(name) + if name == "" { + return repository.CalendarSet{}, ErrInvalidCalendarSetName + } + + renamed, err := s.sets.Rename(ctx, id, userID, workspaceID, name) + if err != nil { + return repository.CalendarSet{}, fmt.Errorf("rename calendar set: %w", err) + } + return renamed, nil +} + +// Delete removes id, scoped to userID and workspaceID. Destroys no Calendar +// and no Event — only the Set and its calendar_set_members rows. +func (s *CalendarSetService) Delete(ctx context.Context, userID, workspaceID, id int64) error { + if err := s.sets.Delete(ctx, id, userID, workspaceID); err != nil { + return fmt.Errorf("delete calendar set: %w", err) + } + return nil +} diff --git a/server/internal/service/graph.go b/server/internal/service/graph.go index 0b85ac0..be10344 100644 --- a/server/internal/service/graph.go +++ b/server/internal/service/graph.go @@ -65,6 +65,7 @@ type Graph struct { WorkspaceRepo *repository.WorkspaceRepository WorkspaceInviteRepo *repository.WorkspaceInviteRepository GroupRepo *repository.GroupRepository + CalendarSetRepo *repository.CalendarSetRepository NotificationRepo *repository.NotificationRepository AppPasswordRepo *repository.AppPasswordRepository FiredReminderRepo *repository.FiredReminderRepository @@ -90,6 +91,7 @@ type Graph struct { Calendars *CalendarService Events *EventService Groups *GroupService + CalendarSets *CalendarSetService Imports *ImportService Notifications *NotificationService Subscriptions *SubscribeService @@ -216,6 +218,7 @@ func NewGraph(sqlDB *sql.DB, cfg config.Config, opts ...GraphOption) (*Graph, er WorkspaceRepo: repository.NewWorkspaceRepository(sqlDB), WorkspaceInviteRepo: repository.NewWorkspaceInviteRepository(sqlDB), GroupRepo: repository.NewGroupRepository(sqlDB), + CalendarSetRepo: repository.NewCalendarSetRepository(sqlDB), NotificationRepo: repository.NewNotificationRepository(sqlDB), AppPasswordRepo: repository.NewAppPasswordRepository(sqlDB), FiredReminderRepo: repository.NewFiredReminderRepository(sqlDB), @@ -226,6 +229,7 @@ func NewGraph(sqlDB *sql.DB, cfg config.Config, opts ...GraphOption) (*Graph, er g.RateLimiter = NewAuthRateLimiter(g.RateLimitRepo, cfg.AuthRateLimitPerEmail, cfg.AuthRateLimitPerIP, cfg.RegisterRateLimitPerIP) g.Workspaces = NewWorkspaceService(sqlDB, g.WorkspaceRepo, g.WorkspaceInviteRepo, g.CalendarRepo, g.ShareRepo) g.Groups = NewGroupService(g.GroupRepo, g.WorkspaceRepo) + g.CalendarSets = NewCalendarSetService(g.CalendarSetRepo) g.Calendars = NewCalendarService(sqlDB, g.CalendarRepo, g.SourceRepo, g.ShareRepo, g.UserRepo, g.EventReminderRepo, g.DefaultReminderRepo, g.ExplicitReminderRepo, g.ColorOverrideRepo, g.ExposureRepo, g.WorkspaceRepo, g.GroupShareRepo, g.GroupRepo) g.Auth = NewAuthService(sqlDB, g.UserRepo, g.SessionRepo, g.Workspaces, g.WorkspaceInviteRepo, g.Calendars, g.AttendeeRepo, g.JWTSecret, cfg.InitialName, cfg.InitialEmail, cfg.InitialPassword, cfg.EnableSignups) // mailOutbox is nil on a deployment with no SMTP transport configured diff --git a/src/lib/calendarSetsApi.ts b/src/lib/calendarSetsApi.ts new file mode 100644 index 0000000..cadaeff --- /dev/null +++ b/src/lib/calendarSetsApi.ts @@ -0,0 +1,63 @@ +import { authedFetch, errorFromResponse } from "./apiClient"; +import { workspaceHeaders } from "./workspaceHeaders"; + +// Calendar Sets (#301, ADR-0082): a named, private selection of the active +// Workspace's Calendars belonging to one User. Scoped to the currently +// active Workspace via workspaceHeaders, the same as every other +// Workspace-scoped call — no sharing mechanism, no Role, so unlike groupsApi +// there is no membership-management surface here yet. + +export interface CalendarSet { + id: number; + name: string; +} + +export const calendarSetsApi = { + // Every Calendar Set the caller owns in the active Workspace. + async list(accessToken: string): Promise { + const response = await authedFetch(accessToken, "/api/calendar-sets/", { + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as CalendarSet[]; + }, + + // Creates a new Calendar Set named name in the active Workspace. + // Membership is added separately. + async create(accessToken: string, name: string): Promise { + const response = await authedFetch(accessToken, "/api/calendar-sets/", { + method: "POST", + credentials: "include", + headers: workspaceHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ name }), + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as CalendarSet; + }, + + // Renames id. + async rename(accessToken: string, id: number, name: string): Promise { + const response = await authedFetch(accessToken, `/api/calendar-sets/${id}`, { + method: "PATCH", + credentials: "include", + headers: workspaceHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ name }), + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as CalendarSet; + }, + + // Deletes id outright. Destroys no Calendar and no Event. + async remove(accessToken: string, id: number): Promise { + const response = await authedFetch(accessToken, `/api/calendar-sets/${id}`, { + method: "DELETE", + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + }, +}; diff --git a/src/lib/calendarSetsStore.ts b/src/lib/calendarSetsStore.ts new file mode 100644 index 0000000..5c6d0bd --- /dev/null +++ b/src/lib/calendarSetsStore.ts @@ -0,0 +1,45 @@ +import { create } from "zustand"; +import { useAuthStore } from "./authStore"; +import { type CalendarSet, calendarSetsApi } from "./calendarSetsApi"; + +// The Calendar Sets Settings Section's data source (#301): every Calendar +// Set the caller owns in the active Workspace. Mirrors groupsStore's shape, +// minus membership management — a Set's membership is #302's. +interface CalendarSetsState { + calendarSets: CalendarSet[]; + fetchCalendarSets: () => Promise; + createCalendarSet: (name: string) => Promise; + renameCalendarSet: (id: number, name: string) => Promise; + deleteCalendarSet: (id: number) => Promise; +} + +function requireAccessToken(): string { + const accessToken = useAuthStore.getState().accessToken; + if (!accessToken) throw new Error("Not authenticated."); + return accessToken; +} + +export const useCalendarSetsStore = create((set, get) => ({ + calendarSets: [], + + fetchCalendarSets: async () => { + const calendarSets = await calendarSetsApi.list(requireAccessToken()); + set({ calendarSets }); + }, + + createCalendarSet: async (name) => { + const created = await calendarSetsApi.create(requireAccessToken(), name); + set({ calendarSets: [...get().calendarSets, created] }); + return created; + }, + + renameCalendarSet: async (id, name) => { + const renamed = await calendarSetsApi.rename(requireAccessToken(), id, name); + set({ calendarSets: get().calendarSets.map((s) => (s.id === id ? renamed : s)) }); + }, + + deleteCalendarSet: async (id) => { + await calendarSetsApi.remove(requireAccessToken(), id); + set({ calendarSets: get().calendarSets.filter((s) => s.id !== id) }); + }, +})); diff --git a/src/settings/CalendarSetsSection.tsx b/src/settings/CalendarSetsSection.tsx new file mode 100644 index 0000000..9d31d7d --- /dev/null +++ b/src/settings/CalendarSetsSection.tsx @@ -0,0 +1,158 @@ +import { useEffect, useState } from "react"; +import { Check, Pencil, Plus, Trash2, X } from "lucide-react"; +import { Button } from "../components/ui/Button"; +import { IconButton } from "../components/ui/IconButton"; +import { Input } from "../components/ui/Input"; +import { useAsyncAction } from "../hooks/useAsyncAction"; +import { errorMessage } from "../lib/errorMessage"; +import { useWorkspacesStore } from "../lib/workspacesStore"; +import { useCalendarSetsStore } from "../lib/calendarSetsStore"; +import type { CalendarSet } from "../lib/calendarSetsApi"; +import { DeleteCalendarSetDialog } from "./DeleteCalendarSetDialog"; + +// The Calendar Sets Settings Section (#301, ADR-0082): create, rename and +// delete the caller's own Calendar Sets — a named, private selection of the +// active Workspace's Calendars. Sits in Settings' Personal group because a +// Set is private outright: no sharing mechanism, no Role, no Admin surface, +// unlike GroupsSection this needs no Owner/Admin gate, since every route is +// already scoped to the caller by (user, active workspace). Re-renders on +// Workspace switch, mirroring GroupsSection's own effect. +export function CalendarSetsSection() { + const activeWorkspaceId = useWorkspacesStore((state) => state.activeWorkspaceId); + + const calendarSets = useCalendarSetsStore((state) => state.calendarSets); + const fetchCalendarSets = useCalendarSetsStore((state) => state.fetchCalendarSets); + const createCalendarSet = useCalendarSetsStore((state) => state.createCalendarSet); + const renameCalendarSet = useCalendarSetsStore((state) => state.renameCalendarSet); + + const [loadError, setLoadError] = useState(null); + const [name, setName] = useState(""); + const [editingId, setEditingId] = useState(null); + const [editingName, setEditingName] = useState(""); + const [deleteTarget, setDeleteTarget] = useState(null); + const { isSubmitting: isCreating, error: createError, run: runCreate } = useAsyncAction(); + const { isSubmitting: isRenaming, error: renameError, setError: setRenameError, run: runRename } = useAsyncAction(); + + useEffect(() => { + if (activeWorkspaceId === null) return; + fetchCalendarSets() + .then(() => setLoadError(null)) + .catch((err) => setLoadError(errorMessage(err))); + }, [activeWorkspaceId, fetchCalendarSets]); + + async function handleCreate(domEvent: React.FormEvent) { + domEvent.preventDefault(); + if (!name.trim()) return; + + await runCreate(async () => { + await createCalendarSet(name.trim()); + setName(""); + }); + } + + function startEditing(calendarSet: CalendarSet) { + setEditingId(calendarSet.id); + setEditingName(calendarSet.name); + setRenameError(null); + } + + async function handleRename(calendarSet: CalendarSet) { + const trimmed = editingName.trim(); + if (!trimmed || trimmed === calendarSet.name) { + setEditingId(null); + return; + } + + await runRename(async () => { + await renameCalendarSet(calendarSet.id, trimmed); + setEditingId(null); + }); + } + + return ( +

+

Calendar sets

+

+ Save named selections of this workspace's calendars, private to you, so you can switch your view in one + click. +

+ + {loadError &&

{loadError}

} + +
+ setName(domEvent.target.value)} + className="w-72" + /> + +
+ + {createError &&

{createError}

} + +
    + {calendarSets.map((calendarSet) => { + const isEditing = editingId === calendarSet.id; + return ( +
  • +
    + {isEditing ? ( + setEditingName(domEvent.target.value)} + onKeyDown={(domEvent) => { + if (domEvent.key === "Enter") handleRename(calendarSet); + if (domEvent.key === "Escape") setEditingId(null); + }} + className="flex-1" + aria-label={`Rename ${calendarSet.name}`} + /> + ) : ( +

    {calendarSet.name}

    + )} + +
    + {isEditing ? ( + <> + handleRename(calendarSet)} + disabled={isRenaming} + aria-label={`Save ${calendarSet.name}`} + > + + + setEditingId(null)} disabled={isRenaming} aria-label="Cancel rename"> + + + + ) : ( + <> + startEditing(calendarSet)} aria-label={`Rename ${calendarSet.name}`}> + + + setDeleteTarget(calendarSet)} aria-label={`Delete ${calendarSet.name}`}> + + + + )} +
    +
    + {isEditing && renameError &&

    {renameError}

    } +
  • + ); + })} + + {calendarSets.length === 0 && !loadError && ( +

    No calendar sets yet.

    + )} +
+ + {deleteTarget && setDeleteTarget(null)} />} +
+ ); +} diff --git a/src/settings/DeleteCalendarSetDialog.tsx b/src/settings/DeleteCalendarSetDialog.tsx new file mode 100644 index 0000000..1325d3d --- /dev/null +++ b/src/settings/DeleteCalendarSetDialog.tsx @@ -0,0 +1,62 @@ +import { Dialog } from "@base-ui/react/dialog"; +import { Button } from "../components/ui/Button"; +import { buttonClasses } from "../components/ui/buttonClasses"; +import { useAsyncAction } from "../hooks/useAsyncAction"; +import { useCalendarSetsStore } from "../lib/calendarSetsStore"; +import type { CalendarSet } from "../lib/calendarSetsApi"; + +interface DeleteCalendarSetDialogProps { + calendarSet: CalendarSet; + onClose: () => void; +} + +// Deletes a Calendar Set outright (#301, ADR-0082). Destroys no Calendar and +// no Event — tidying a private view is never a destructive act. +export function DeleteCalendarSetDialog({ calendarSet, onClose }: DeleteCalendarSetDialogProps) { + const deleteCalendarSet = useCalendarSetsStore((state) => state.deleteCalendarSet); + const { isSubmitting, error, run } = useAsyncAction(); + + async function handleConfirm() { + await run(async () => { + await deleteCalendarSet(calendarSet.id); + onClose(); + }); + } + + return ( + { + if (!open && !isSubmitting) onClose(); + }} + > + + + + Delete {calendarSet.name}? + + This removes the calendar set. No calendar and no event is deleted, and this cannot be undone. + + + {error && ( +

+ {error} +

+ )} + +
+ + Cancel + + +
+
+
+
+ ); +} diff --git a/src/settings/settingsSections.tsx b/src/settings/settingsSections.tsx index ca8a4ff..7d652d3 100644 --- a/src/settings/settingsSections.tsx +++ b/src/settings/settingsSections.tsx @@ -5,6 +5,7 @@ import { Boxes, Cable, KeyRound, + Layers, SlidersHorizontal, UserRound, UsersRound, @@ -18,6 +19,7 @@ import { ReminderDeliverySection } from "./ReminderDeliverySection"; import { ImportExportSection } from "./ImportExportSection"; import { MembersSection } from "./MembersSection"; import { GroupsSection } from "./GroupsSection"; +import { CalendarSetsSection } from "./CalendarSetsSection"; export type SettingsGroup = "personal" | "workspace"; @@ -47,11 +49,18 @@ export interface SettingsSection { // only two people-shaped concepts) never sit adjacent to a third. Connections // (#285) sits right after Account, distinctly iconed and labelled, so a // third-party Provider grant is never confused with the User's own login. +// Calendar sets (#301, ADR-0082) is Personal's first workspace-dependent +// Section — its contents re-render on Workspace switch, unlike every other +// Personal Section — because privacy, not workspace-independence, is what +// the Personal grouping communicates: a Set has no sharing mechanism, no +// Role and no Admin surface, so it sits with Preferences rather than beside +// Admin-managed Members and Groups. export function getSettingsSections(): SettingsSection[] { return [ { path: "preferences", label: "Preferences", group: "personal", icon: SlidersHorizontal, element: }, { path: "account", label: "Account", group: "personal", icon: UserRound, element: }, { path: "connections", label: "Connections", group: "personal", icon: Cable, element: }, + { path: "calendar-sets", label: "Calendar sets", group: "personal", icon: Layers, element: }, { path: "members", label: "Members", group: "workspace", icon: UsersRound, element: }, { path: "groups", label: "Groups", group: "workspace", icon: Boxes, element: }, { path: "app-passwords", label: "App passwords", group: "personal", icon: KeyRound, element: }, From 0aa2a43d778acaa65478e727ff6146f9691e407e Mon Sep 17 00:00:00 2001 From: XiovV Date: Fri, 11 Sep 2026 11:28:01 +0200 Subject: [PATCH 06/33] Putting Calendars into a Calendar Set: membership with immediate effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PUT/DELETE /api/calendar-sets/{id}/calendars/{calendarId} to add and remove a Calendar from a Set. Adding validates independently that the Calendar belongs to the active Workspace and that the caller has Access to it, both collapsing to 404 like the Set's own scoping. Removal relies on the existing calendar_set_members ON DELETE CASCADE for the delete/unsubscribe/unshare cascade, so an emptied Set survives with no reconciliation code. GET /api/calendar-sets now embeds each Set's member Calendar ids (ADR-0082's REST surface), which also fixes Rename to stop reporting a renamed Set as membership-free. On the frontend, a new membership dialog in the Calendar Sets Settings Section toggles a Calendar in or out of a Set with no save step, grouped exactly as the sidebar groups Calendars — that grouping logic moves out of CalendarList into a shared src/lib/calendarGrouping.ts so the two can't drift apart. Closes #302 --- server/internal/handlers/calendar_set.go | 72 +++- server/internal/handlers/calendar_set_test.go | 327 ++++++++++++++++++ server/internal/repository/calendar_set.go | 50 +++ server/internal/router/router.go | 8 + server/internal/service/calendar_set.go | 93 ++++- server/internal/service/graph.go | 4 +- src/components/layout/CalendarList.tsx | 76 +--- src/lib/calendarGrouping.ts | 70 ++++ src/lib/calendarSetsApi.ts | 34 +- src/lib/calendarSetsStore.ts | 34 +- src/settings/CalendarSetMembershipDialog.tsx | 125 +++++++ src/settings/CalendarSetsSection.tsx | 13 +- 12 files changed, 814 insertions(+), 92 deletions(-) create mode 100644 src/lib/calendarGrouping.ts create mode 100644 src/settings/CalendarSetMembershipDialog.tsx diff --git a/server/internal/handlers/calendar_set.go b/server/internal/handlers/calendar_set.go index c225022..2dc020b 100644 --- a/server/internal/handlers/calendar_set.go +++ b/server/internal/handlers/calendar_set.go @@ -9,6 +9,8 @@ package handlers import ( "net/http" + "github.com/go-chi/chi/v5" + "github.com/XiovV/calich/server/internal/httpauth" "github.com/XiovV/calich/server/internal/httpresponse" "github.com/XiovV/calich/server/internal/repository" @@ -24,12 +26,28 @@ func NewCalendarSetHandler(sets *service.CalendarSetService) *CalendarSetHandler } type calendarSetResponse struct { - ID int64 `json:"id"` - Name string `json:"name"` + ID int64 `json:"id"` + Name string `json:"name"` + CalendarIDs []string `json:"calendarIds"` } +// toCalendarSetResponse renders a just-created Set, which carries no +// membership of its own yet (Create takes a name only — membership is added +// separately). Create-only: a renamed Set may already have members, so +// Rename goes through toCalendarSetWithMembersResponse instead. func toCalendarSetResponse(s repository.CalendarSet) calendarSetResponse { - return calendarSetResponse{ID: s.ID, Name: s.Name} + return calendarSetResponse{ID: s.ID, Name: s.Name, CalendarIDs: []string{}} +} + +// toCalendarSetWithMembersResponse renders List's shape (ADR-0082): a Set +// alongside its member Calendar ids, so the switcher and the membership +// dialog both have what they need the moment they have the Set itself. +func toCalendarSetWithMembersResponse(s service.CalendarSetWithMembers) calendarSetResponse { + ids := s.CalendarIDs + if ids == nil { + ids = []string{} + } + return calendarSetResponse{ID: s.ID, Name: s.Name, CalendarIDs: ids} } // calendarSetErrors renders the sentinels every CalendarSetService call can @@ -54,7 +72,7 @@ func (h *CalendarSetHandler) List(w http.ResponseWriter, r *http.Request) { response := make([]calendarSetResponse, len(sets)) for i, s := range sets { - response[i] = toCalendarSetResponse(s) + response[i] = toCalendarSetWithMembersResponse(s) } httpresponse.JSON(w, http.StatusOK, response) @@ -106,7 +124,7 @@ func (h *CalendarSetHandler) Rename(w http.ResponseWriter, r *http.Request) { return } - httpresponse.JSON(w, http.StatusOK, toCalendarSetResponse(set)) + httpresponse.JSON(w, http.StatusOK, toCalendarSetWithMembersResponse(set)) } // Delete removes a Calendar Set outright. Destroys no Calendar and no Event. @@ -124,3 +142,47 @@ func (h *CalendarSetHandler) Delete(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } + +// AddCalendar serves PUT /api/calendar-sets/{id}/calendars/{calendarId}: +// puts a Calendar into a Set's membership (#302, ADR-0082). PUT, not POST — +// re-adding a Calendar already in the Set answers 204 same as a fresh add. +// Refuses (as calendarSetErrors' repository.ErrNotFound case) a Calendar +// from another Workspace or one the caller has no Access to, exactly as it +// refuses a Set that isn't the caller's own. +func (h *CalendarSetHandler) AddCalendar(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + id, ok := parseInt64Param(w, r, "id") + if !ok { + return + } + calendarID := chi.URLParam(r, "calendarId") + + if err := h.sets.AddCalendar(r.Context(), userID, workspaceID, id, calendarID); respondError(w, err, calendarSetErrors, "failed to add calendar to calendar set") { + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// RemoveCalendar serves DELETE /api/calendar-sets/{id}/calendars/{calendarId}: +// takes a Calendar out of a Set's membership (#302, ADR-0082). The reverse +// cascade — a deleted, unsubscribed, or unshared Calendar leaving every Set +// it was in — is the calendars table's own ON DELETE CASCADE on +// calendar_set_members, not this handler; a Set emptied that way survives, +// same as one emptied through this route. +func (h *CalendarSetHandler) RemoveCalendar(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + id, ok := parseInt64Param(w, r, "id") + if !ok { + return + } + calendarID := chi.URLParam(r, "calendarId") + + if err := h.sets.RemoveCalendar(r.Context(), userID, workspaceID, id, calendarID); respondError(w, err, calendarSetErrors, "failed to remove calendar from calendar set") { + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/internal/handlers/calendar_set_test.go b/server/internal/handlers/calendar_set_test.go index 3a03f94..8e7c6c1 100644 --- a/server/internal/handlers/calendar_set_test.go +++ b/server/internal/handlers/calendar_set_test.go @@ -42,9 +42,20 @@ func newCalendarSetHandlerTestServer(t *testing.T) *calendarSetHandlerTestServer authHandler := NewAuthHandler(auth, g.RateLimiter, false, false, false, true) calendarSetHandler := NewCalendarSetHandler(sets) + // Membership (#302) validates against a real Calendar, so this server + // also needs enough of /api/calendars to create and delete one — Create + // for setup, Delete to exercise the cascade leaving a Set intact. + calendarHandler := NewCalendarHandler(g.Calendars, g.Events, g.Imports, g.Subscriptions, g.Connections, g.AttachmentStore) r := chi.NewRouter() r.Post("/api/auth/register", authHandler.Register) + r.Route("/api/calendars", func(r chi.Router) { + r.Use(httpauth.RequireAuth(auth)) + r.Use(httpauth.RequireEnabledUser(auth)) + + r.With(httpauth.RequireWorkspace(workspaces)).Post("/", calendarHandler.Create) + r.Delete("/{id}", calendarHandler.Delete) + }) r.Route("/api/calendar-sets", func(r chi.Router) { r.Use(httpauth.RequireAuth(auth)) r.Use(httpauth.RequireEnabledUser(auth)) @@ -54,6 +65,8 @@ func newCalendarSetHandlerTestServer(t *testing.T) *calendarSetHandlerTestServer r.Post("/", calendarSetHandler.Create) r.Patch("/{id}", calendarSetHandler.Rename) r.Delete("/{id}", calendarSetHandler.Delete) + r.Put("/{id}/calendars/{calendarId}", calendarSetHandler.AddCalendar) + r.Delete("/{id}/calendars/{calendarId}", calendarSetHandler.RemoveCalendar) }) srv := httptest.NewServer(r) @@ -96,6 +109,43 @@ func (s *calendarSetHandlerTestServer) register(t *testing.T, username string) ( return logged.AccessToken, user.ID, workspaces[0].ID } +// createSet creates a Calendar Set named name for accessToken in +// workspaceID and returns its id, failing the test on anything but 201. +func (s *calendarSetHandlerTestServer) createSet(t *testing.T, accessToken string, workspaceID int64, name string) int64 { + t.Helper() + + resp := s.do(t, http.MethodPost, "/api/calendar-sets/", accessToken, workspaceID, createCalendarSetRequest{Name: name}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 creating calendar set, got %d", resp.StatusCode) + } + var set calendarSetResponse + if err := json.NewDecoder(resp.Body).Decode(&set); err != nil { + t.Fatalf("decode create calendar set response: %v", err) + } + return set.ID +} + +// getSet finds id in accessToken's List of workspaceID's Calendar Sets, +// failing the test if it isn't there. +func (s *calendarSetHandlerTestServer) getSet(t *testing.T, accessToken string, workspaceID, id int64) calendarSetResponse { + t.Helper() + + resp := s.do(t, http.MethodGet, "/api/calendar-sets/", accessToken, workspaceID, nil) + defer resp.Body.Close() + var list []calendarSetResponse + if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { + t.Fatalf("decode calendar sets list: %v", err) + } + for _, set := range list { + if set.ID == id { + return set + } + } + t.Fatalf("calendar set %d not found in list %v", id, list) + return calendarSetResponse{} +} + func (s *calendarSetHandlerTestServer) do(t *testing.T, method, path, accessToken string, workspaceID int64, body any) *http.Response { t.Helper() @@ -279,3 +329,280 @@ func TestCalendarSetHandler_SecondUserCannotReadRenameOrDeleteFirstsSet(t *testi t.Fatalf("expected alice's set to survive untouched, got %v", aliceList) } } + +// addCalendarToSet PUTs calendarID into setID's membership. +func (s *calendarSetHandlerTestServer) addCalendarToSet(t *testing.T, accessToken string, workspaceID, setID int64, calendarID string) *http.Response { + t.Helper() + return s.do(t, http.MethodPut, "/api/calendar-sets/"+strconv.FormatInt(setID, 10)+"/calendars/"+calendarID, accessToken, workspaceID, nil) +} + +// removeCalendarFromSet DELETEs calendarID out of setID's membership. +func (s *calendarSetHandlerTestServer) removeCalendarFromSet(t *testing.T, accessToken string, workspaceID, setID int64, calendarID string) *http.Response { + t.Helper() + return s.do(t, http.MethodDelete, "/api/calendar-sets/"+strconv.FormatInt(setID, 10)+"/calendars/"+calendarID, accessToken, workspaceID, nil) +} + +func TestCalendarSetHandler_AddAndRemoveCalendar(t *testing.T) { + s := newCalendarSetHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + + setID := s.createSet(t, token, workspaceID, "Work") + calendarID := "11111111-1111-1111-1111-111111111111" + createResp := createCalendar(t, s.srv.URL, token, strconv.FormatInt(workspaceID, 10), calendarID, "Personal", "#12809CFF") + defer createResp.Body.Close() + if createResp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 creating calendar, got %d", createResp.StatusCode) + } + + addResp := s.addCalendarToSet(t, token, workspaceID, setID, calendarID) + defer addResp.Body.Close() + if addResp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204 adding calendar, got %d", addResp.StatusCode) + } + + set := s.getSet(t, token, workspaceID, setID) + if len(set.CalendarIDs) != 1 || set.CalendarIDs[0] != calendarID { + t.Fatalf("expected set to contain %q, got %v", calendarID, set.CalendarIDs) + } + + // PUT is idempotent: re-adding an already-member Calendar is a no-op, + // not a conflict. + addAgainResp := s.addCalendarToSet(t, token, workspaceID, setID, calendarID) + defer addAgainResp.Body.Close() + if addAgainResp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204 re-adding calendar, got %d", addAgainResp.StatusCode) + } + set = s.getSet(t, token, workspaceID, setID) + if len(set.CalendarIDs) != 1 { + t.Fatalf("expected re-adding to stay a single membership, got %v", set.CalendarIDs) + } + + removeResp := s.removeCalendarFromSet(t, token, workspaceID, setID, calendarID) + defer removeResp.Body.Close() + if removeResp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204 removing calendar, got %d", removeResp.StatusCode) + } + + set = s.getSet(t, token, workspaceID, setID) + if len(set.CalendarIDs) != 0 { + t.Fatalf("expected set to be empty after removal, got %v", set.CalendarIDs) + } +} + +// TestCalendarSetHandler_Rename_PreservesMembership guards against Rename's +// response silently truncating a populated Set's membership to empty: the +// frontend store replaces its whole cached Set with whatever Rename +// answers, so a wrong CalendarIDs here would wipe a Set's membership from +// the UI on every rename. +func TestCalendarSetHandler_Rename_PreservesMembership(t *testing.T) { + s := newCalendarSetHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + + setID := s.createSet(t, token, workspaceID, "Work") + calendarID := "77777777-7777-7777-7777-777777777777" + createResp := createCalendar(t, s.srv.URL, token, strconv.FormatInt(workspaceID, 10), calendarID, "Personal", "#12809CFF") + defer createResp.Body.Close() + if createResp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 creating calendar, got %d", createResp.StatusCode) + } + + addResp := s.addCalendarToSet(t, token, workspaceID, setID, calendarID) + defer addResp.Body.Close() + if addResp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204 adding calendar, got %d", addResp.StatusCode) + } + + renameResp := s.do(t, http.MethodPatch, "/api/calendar-sets/"+strconv.FormatInt(setID, 10), token, workspaceID, renameCalendarSetRequest{Name: "Weekend"}) + defer renameResp.Body.Close() + if renameResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 renaming, got %d", renameResp.StatusCode) + } + var renamed calendarSetResponse + if err := json.NewDecoder(renameResp.Body).Decode(&renamed); err != nil { + t.Fatalf("decode rename response: %v", err) + } + if renamed.Name != "Weekend" { + t.Fatalf("expected renamed name %q, got %q", "Weekend", renamed.Name) + } + if len(renamed.CalendarIDs) != 1 || renamed.CalendarIDs[0] != calendarID { + t.Fatalf("expected the rename response to carry the set's existing membership, got %v", renamed.CalendarIDs) + } +} + +func TestCalendarSetHandler_AddCalendar_FromAnotherWorkspaceRefused(t *testing.T) { + s := newCalendarSetHandlerTestServer(t) + token, userID, firstWorkspaceID := s.register(t, "alice") + + setID := s.createSet(t, token, firstWorkspaceID, "Work") + + secondWorkspace, err := s.graph.Workspaces.CreateForOwner(context.Background(), userID, "Second workspace") + if err != nil { + t.Fatalf("create second workspace: %v", err) + } + + calendarID := "22222222-2222-2222-2222-222222222222" + createResp := createCalendar(t, s.srv.URL, token, strconv.FormatInt(secondWorkspace.ID, 10), calendarID, "Other workspace", "#12809CFF") + defer createResp.Body.Close() + if createResp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 creating calendar in the second workspace, got %d", createResp.StatusCode) + } + + addResp := s.addCalendarToSet(t, token, firstWorkspaceID, setID, calendarID) + defer addResp.Body.Close() + if addResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 adding a calendar from another workspace, got %d", addResp.StatusCode) + } + + set := s.getSet(t, token, firstWorkspaceID, setID) + if len(set.CalendarIDs) != 0 { + t.Fatalf("expected the refused add to leave the set empty, got %v", set.CalendarIDs) + } +} + +func TestCalendarSetHandler_AddCalendar_NoAccessRefused(t *testing.T) { + s := newCalendarSetHandlerTestServer(t) + aliceToken, _, workspaceID := s.register(t, "alice") + bobToken, bobID, _ := s.register(t, "bob") + // bob is a Member of alice's Workspace, so this proves the Access check + // is independent of the Workspace check: same Workspace, still refused, + // because alice owns bob's Calendar not at all and holds no Share on it. + if _, err := s.graph.DB.Exec("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", workspaceID, bobID, "member"); err != nil { + t.Fatalf("add bob to alice's workspace: %v", err) + } + + setID := s.createSet(t, aliceToken, workspaceID, "Work") + + calendarID := "33333333-3333-3333-3333-333333333333" + createResp := createCalendar(t, s.srv.URL, bobToken, strconv.FormatInt(workspaceID, 10), calendarID, "Bob's calendar", "#12809CFF") + defer createResp.Body.Close() + if createResp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 creating bob's calendar, got %d", createResp.StatusCode) + } + + addResp := s.addCalendarToSet(t, aliceToken, workspaceID, setID, calendarID) + defer addResp.Body.Close() + if addResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 adding a calendar alice has no access to, got %d", addResp.StatusCode) + } + + set := s.getSet(t, aliceToken, workspaceID, setID) + if len(set.CalendarIDs) != 0 { + t.Fatalf("expected the refused add to leave the set empty, got %v", set.CalendarIDs) + } +} + +func TestCalendarSetHandler_CalendarCanBelongToSeveralSets(t *testing.T) { + s := newCalendarSetHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + + workSetID := s.createSet(t, token, workspaceID, "Work") + weekendSetID := s.createSet(t, token, workspaceID, "Weekend") + + calendarID := "44444444-4444-4444-4444-444444444444" + createResp := createCalendar(t, s.srv.URL, token, strconv.FormatInt(workspaceID, 10), calendarID, "Shared calendar", "#12809CFF") + defer createResp.Body.Close() + if createResp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 creating calendar, got %d", createResp.StatusCode) + } + + for _, setID := range []int64{workSetID, weekendSetID} { + addResp := s.addCalendarToSet(t, token, workspaceID, setID, calendarID) + defer addResp.Body.Close() + if addResp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204 adding calendar to set %d, got %d", setID, addResp.StatusCode) + } + } + + workSet := s.getSet(t, token, workspaceID, workSetID) + weekendSet := s.getSet(t, token, workspaceID, weekendSetID) + if len(workSet.CalendarIDs) != 1 || workSet.CalendarIDs[0] != calendarID { + t.Fatalf("expected Work to contain the calendar, got %v", workSet.CalendarIDs) + } + if len(weekendSet.CalendarIDs) != 1 || weekendSet.CalendarIDs[0] != calendarID { + t.Fatalf("expected Weekend to also contain the calendar, got %v", weekendSet.CalendarIDs) + } +} + +func TestCalendarSetHandler_SecondUserCannotAddOrRemoveFromFirstsSet(t *testing.T) { + s := newCalendarSetHandlerTestServer(t) + aliceToken, _, workspaceID := s.register(t, "alice") + bobToken, bobID, _ := s.register(t, "bob") + if _, err := s.graph.DB.Exec("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", workspaceID, bobID, "member"); err != nil { + t.Fatalf("add bob to alice's workspace: %v", err) + } + + setID := s.createSet(t, aliceToken, workspaceID, "Work") + calendarID := "55555555-5555-5555-5555-555555555555" + createResp := createCalendar(t, s.srv.URL, aliceToken, strconv.FormatInt(workspaceID, 10), calendarID, "Personal", "#12809CFF") + defer createResp.Body.Close() + if createResp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 creating calendar, got %d", createResp.StatusCode) + } + + bobAddResp := s.addCalendarToSet(t, bobToken, workspaceID, setID, calendarID) + defer bobAddResp.Body.Close() + if bobAddResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 adding to another user's set, got %d", bobAddResp.StatusCode) + } + + aliceAddResp := s.addCalendarToSet(t, aliceToken, workspaceID, setID, calendarID) + defer aliceAddResp.Body.Close() + if aliceAddResp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204, got %d", aliceAddResp.StatusCode) + } + + bobRemoveResp := s.removeCalendarFromSet(t, bobToken, workspaceID, setID, calendarID) + defer bobRemoveResp.Body.Close() + if bobRemoveResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 removing from another user's set, got %d", bobRemoveResp.StatusCode) + } + + set := s.getSet(t, aliceToken, workspaceID, setID) + if len(set.CalendarIDs) != 1 || set.CalendarIDs[0] != calendarID { + t.Fatalf("expected alice's membership to survive bob's attempts, got %v", set.CalendarIDs) + } +} + +func TestCalendarSetHandler_DeletingCalendarCascadesOutOfSet(t *testing.T) { + s := newCalendarSetHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + + setID := s.createSet(t, token, workspaceID, "Work") + calendarID := "66666666-6666-6666-6666-666666666666" + createResp := createCalendar(t, s.srv.URL, token, strconv.FormatInt(workspaceID, 10), calendarID, "Personal", "#12809CFF") + defer createResp.Body.Close() + if createResp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 creating calendar, got %d", createResp.StatusCode) + } + + addResp := s.addCalendarToSet(t, token, workspaceID, setID, calendarID) + defer addResp.Body.Close() + if addResp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204 adding calendar, got %d", addResp.StatusCode) + } + + deleteReq, err := http.NewRequest(http.MethodDelete, s.srv.URL+"/api/calendars/"+calendarID, nil) + if err != nil { + t.Fatalf("build delete calendar request: %v", err) + } + deleteReq.Header.Set("Authorization", "Bearer "+token) + deleteResp, err := http.DefaultClient.Do(deleteReq) + if err != nil { + t.Fatalf("delete calendar: %v", err) + } + defer deleteResp.Body.Close() + if deleteResp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204 deleting calendar, got %d", deleteResp.StatusCode) + } + + // The cascade (calendar_set_members.calendar_id ON DELETE CASCADE, + // ADR-0082) empties the Set's membership with no reconciliation code and + // no notification — and the Set itself survives. + set := s.getSet(t, token, workspaceID, setID) + if set.Name != "Work" { + t.Fatalf("expected the set to survive with its name intact, got %v", set) + } + if len(set.CalendarIDs) != 0 { + t.Fatalf("expected the cascade to empty the set's membership, got %v", set.CalendarIDs) + } +} diff --git a/server/internal/repository/calendar_set.go b/server/internal/repository/calendar_set.go index 001766e..4a5f64f 100644 --- a/server/internal/repository/calendar_set.go +++ b/server/internal/repository/calendar_set.go @@ -117,3 +117,53 @@ func (r *CalendarSetRepository) Delete(ctx context.Context, id, userID, workspac } return requireAffected(res) } + +// AddCalendar puts calendarID into setID's membership (#302, ADR-0082). +// INSERT OR IGNORE rather than erroring on a Calendar already in the Set — +// the endpoint above this is a PUT, so re-adding an existing member is a +// no-op, not a conflict. +func (r *CalendarSetRepository) AddCalendar(ctx context.Context, setID int64, calendarID string) error { + if _, err := r.db.ExecContext(ctx, + `INSERT OR IGNORE INTO calendar_set_members (set_id, calendar_id) VALUES (?, ?)`, + setID, calendarID, + ); err != nil { + return fmt.Errorf("insert calendar set member: %w", err) + } + return nil +} + +// RemoveCalendar takes calendarID out of setID's membership. +func (r *CalendarSetRepository) RemoveCalendar(ctx context.Context, setID int64, calendarID string) error { + res, err := r.db.ExecContext(ctx, + `DELETE FROM calendar_set_members WHERE set_id = ? AND calendar_id = ?`, + setID, calendarID, + ) + if err != nil { + return fmt.Errorf("delete calendar set member: %w", err) + } + return requireAffected(res) +} + +// ListCalendarIDs returns every Calendar id belonging to setID — the +// membership dialog's "which Calendars are already in" answer, and the +// switcher's own eventual read. +func (r *CalendarSetRepository) ListCalendarIDs(ctx context.Context, setID int64) ([]string, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT calendar_id FROM calendar_set_members WHERE set_id = ?`, + setID, + ) + if err != nil { + return nil, fmt.Errorf("list calendar set members: %w", err) + } + defer rows.Close() + + ids := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan calendar set member: %w", err) + } + ids = append(ids, id) + } + return ids, rows.Err() +} diff --git a/server/internal/router/router.go b/server/internal/router/router.go index 615bbba..50b64de 100644 --- a/server/internal/router/router.go +++ b/server/internal/router/router.go @@ -257,6 +257,14 @@ func New(logger *slog.Logger, authHandler *handlers.AuthHandler, calendarHandler r.Post("/", calendarSetHandler.Create) r.Patch("/{id}", calendarSetHandler.Rename) r.Delete("/{id}", calendarSetHandler.Delete) + + // Membership (#302, ADR-0082): add/remove a Calendar, one at a + // time, with immediate effect. CalendarSetService itself + // validates the Calendar independently of the Set — Workspace + // membership and caller Access — so no extra middleware gate is + // needed here either. + r.Put("/{id}/calendars/{calendarId}", calendarSetHandler.AddCalendar) + r.Delete("/{id}/calendars/{calendarId}", calendarSetHandler.RemoveCalendar) }) // Connections (#285, ADR-0050, ADR-0051): Callback sits outside diff --git a/server/internal/service/calendar_set.go b/server/internal/service/calendar_set.go index a06ac13..926b258 100644 --- a/server/internal/service/calendar_set.go +++ b/server/internal/service/calendar_set.go @@ -22,11 +22,21 @@ var ErrInvalidCalendarSetName = errors.New("calendar set name must not be empty" // itself, and a Set belonging to someone else surfaces as // repository.ErrNotFound rather than any distinguishable "forbidden". type CalendarSetService struct { - sets *repository.CalendarSetRepository + sets *repository.CalendarSetRepository + calendars *CalendarService } -func NewCalendarSetService(sets *repository.CalendarSetRepository) *CalendarSetService { - return &CalendarSetService{sets: sets} +func NewCalendarSetService(sets *repository.CalendarSetRepository, calendars *CalendarService) *CalendarSetService { + return &CalendarSetService{sets: sets, calendars: calendars} +} + +// CalendarSetWithMembers pairs a Calendar Set with its member Calendar ids +// (ADR-0082's REST surface) — List's own shape, since the Set switcher and +// the membership dialog both need a Set's membership the moment they have +// the Set itself, with no second round trip. +type CalendarSetWithMembers struct { + repository.CalendarSet + CalendarIDs []string } // Create makes a new Calendar Set named name, owned by userID inside @@ -44,27 +54,47 @@ func (s *CalendarSetService) Create(ctx context.Context, userID, workspaceID int return set, nil } -// ListForUser returns every Calendar Set userID owns inside workspaceID. -func (s *CalendarSetService) ListForUser(ctx context.Context, userID, workspaceID int64) ([]repository.CalendarSet, error) { +// ListForUser returns every Calendar Set userID owns inside workspaceID, +// each with its member Calendar ids. +func (s *CalendarSetService) ListForUser(ctx context.Context, userID, workspaceID int64) ([]CalendarSetWithMembers, error) { sets, err := s.sets.ListForUser(ctx, userID, workspaceID) if err != nil { return nil, fmt.Errorf("list calendar sets: %w", err) } - return sets, nil + + result := make([]CalendarSetWithMembers, len(sets)) + for i, set := range sets { + ids, err := s.sets.ListCalendarIDs(ctx, set.ID) + if err != nil { + return nil, fmt.Errorf("list calendar set members: %w", err) + } + result[i] = CalendarSetWithMembers{CalendarSet: set, CalendarIDs: ids} + } + return result, nil } -// Rename changes id's name, scoped to userID and workspaceID. -func (s *CalendarSetService) Rename(ctx context.Context, userID, workspaceID, id int64, name string) (repository.CalendarSet, error) { +// Rename changes id's name, scoped to userID and workspaceID, and returns +// it with its existing membership intact — unlike a fresh Create, a renamed +// Set is not membership-free, so the response must carry its real +// CalendarIDs rather than an empty placeholder (a client that replaces its +// cached Set wholesale with this response, as calendarSetsStore does, would +// otherwise wipe a populated Set's membership from the UI on every rename). +func (s *CalendarSetService) Rename(ctx context.Context, userID, workspaceID, id int64, name string) (CalendarSetWithMembers, error) { name = strings.TrimSpace(name) if name == "" { - return repository.CalendarSet{}, ErrInvalidCalendarSetName + return CalendarSetWithMembers{}, ErrInvalidCalendarSetName } renamed, err := s.sets.Rename(ctx, id, userID, workspaceID, name) if err != nil { - return repository.CalendarSet{}, fmt.Errorf("rename calendar set: %w", err) + return CalendarSetWithMembers{}, fmt.Errorf("rename calendar set: %w", err) + } + + ids, err := s.sets.ListCalendarIDs(ctx, renamed.ID) + if err != nil { + return CalendarSetWithMembers{}, fmt.Errorf("list calendar set members: %w", err) } - return renamed, nil + return CalendarSetWithMembers{CalendarSet: renamed, CalendarIDs: ids}, nil } // Delete removes id, scoped to userID and workspaceID. Destroys no Calendar @@ -75,3 +105,44 @@ func (s *CalendarSetService) Delete(ctx context.Context, userID, workspaceID, id } return nil } + +// AddCalendar puts calendarID into id's membership (#302, ADR-0082), scoped +// to userID and workspaceID like every other method here. Validates two +// things independently — that calendarID belongs to workspaceID, and that +// userID has at least Viewer Access to it — and collapses either failure to +// repository.ErrNotFound, the same "can't distinguish refusal from absence" +// posture GetByID already applies to the Set itself. Idempotent: a Calendar +// already in the Set is not an error. +func (s *CalendarSetService) AddCalendar(ctx context.Context, userID, workspaceID, id int64, calendarID string) error { + if _, err := s.sets.GetByID(ctx, id, userID, workspaceID); err != nil { + return fmt.Errorf("get calendar set: %w", err) + } + + access, calendar, err := s.calendars.Access(ctx, userID, calendarID) + if err != nil { + return fmt.Errorf("resolve calendar access: %w", err) + } + if !access.CanRead() || calendar.WorkspaceID != workspaceID { + return repository.ErrNotFound + } + + if err := s.sets.AddCalendar(ctx, id, calendarID); err != nil { + return fmt.Errorf("add calendar to calendar set: %w", err) + } + return nil +} + +// RemoveCalendar takes calendarID out of id's membership (#302, ADR-0082), +// scoped like AddCalendar. No fresh Access check: removing a reference the +// caller already curated needs no re-proof that they can still see the +// Calendar, mirroring Delete's own posture toward the Set itself. +func (s *CalendarSetService) RemoveCalendar(ctx context.Context, userID, workspaceID, id int64, calendarID string) error { + if _, err := s.sets.GetByID(ctx, id, userID, workspaceID); err != nil { + return fmt.Errorf("get calendar set: %w", err) + } + + if err := s.sets.RemoveCalendar(ctx, id, calendarID); err != nil { + return fmt.Errorf("remove calendar from calendar set: %w", err) + } + return nil +} diff --git a/server/internal/service/graph.go b/server/internal/service/graph.go index be10344..9920e1e 100644 --- a/server/internal/service/graph.go +++ b/server/internal/service/graph.go @@ -229,8 +229,10 @@ func NewGraph(sqlDB *sql.DB, cfg config.Config, opts ...GraphOption) (*Graph, er g.RateLimiter = NewAuthRateLimiter(g.RateLimitRepo, cfg.AuthRateLimitPerEmail, cfg.AuthRateLimitPerIP, cfg.RegisterRateLimitPerIP) g.Workspaces = NewWorkspaceService(sqlDB, g.WorkspaceRepo, g.WorkspaceInviteRepo, g.CalendarRepo, g.ShareRepo) g.Groups = NewGroupService(g.GroupRepo, g.WorkspaceRepo) - g.CalendarSets = NewCalendarSetService(g.CalendarSetRepo) g.Calendars = NewCalendarService(sqlDB, g.CalendarRepo, g.SourceRepo, g.ShareRepo, g.UserRepo, g.EventReminderRepo, g.DefaultReminderRepo, g.ExplicitReminderRepo, g.ColorOverrideRepo, g.ExposureRepo, g.WorkspaceRepo, g.GroupShareRepo, g.GroupRepo) + // CalendarSets needs Calendars for AddCalendar's Access check (#302, + // ADR-0082), so it's built after. + g.CalendarSets = NewCalendarSetService(g.CalendarSetRepo, g.Calendars) g.Auth = NewAuthService(sqlDB, g.UserRepo, g.SessionRepo, g.Workspaces, g.WorkspaceInviteRepo, g.Calendars, g.AttendeeRepo, g.JWTSecret, cfg.InitialName, cfg.InitialEmail, cfg.InitialPassword, cfg.EnableSignups) // mailOutbox is nil on a deployment with no SMTP transport configured // (ADR-0059, ADR-0060): with nothing able to send an Invitation there is diff --git a/src/components/layout/CalendarList.tsx b/src/components/layout/CalendarList.tsx index e2edbcb..4afdaee 100644 --- a/src/components/layout/CalendarList.tsx +++ b/src/components/layout/CalendarList.tsx @@ -6,6 +6,7 @@ import { CalendarPickerModal } from "../../settings/CalendarPickerModal"; import { IconButton } from "../ui/IconButton"; import { iconButtonClasses } from "../ui/iconButtonClasses"; import { canManageCalendar, isLinkedCalendar, shareCountTooltip, type Calendar } from "../../lib/calendar"; +import { connectionGroupLabel, groupCalendarsForSidebar, UNRESOLVED_CONNECTION } from "../../lib/calendarGrouping"; import { resolveCalendarFill } from "../../lib/calendarColors"; import { useAuthStore } from "../../lib/authStore"; import { useCalendarsStore } from "../../lib/calendarsStore"; @@ -28,12 +29,9 @@ import { ShareCalendarModal } from "./ShareCalendarModal"; // The row's action menu items share this shape so every row type — a click // away from Edit-only, Edit+Export+Delete, or Edit+Refresh+Unsubscribe — is // one list instead of three near-duplicate menus (#189). -// UNRESOLVED_CONNECTION is the grouping key for a Linked Calendar whose -// Connection id is missing from the payload — a state the server should never -// produce, kept so that such a Calendar still renders instead of disappearing. -// The heading reads "Unknown account" and offers no picker button, since there -// is no Connection to re-open it with. -const UNRESOLVED_CONNECTION = Symbol("unresolved connection"); +// UNRESOLVED_CONNECTION's heading (see calendarGrouping.ts) reads "Unknown +// account" here and offers no picker button, since there is no Connection to +// re-open it with. const menuItemClasses = "flex cursor-default items-center px-3 py-1.5 text-body text-ink data-[highlighted]:bg-surface-hover data-[disabled]:pointer-events-none data-[disabled]:opacity-50"; @@ -105,65 +103,11 @@ export function CalendarList() { } | null>(null); const [isConfirmingExport, setIsConfirmingExport] = useState(false); - // A Calendar shared with the viewer is grouped by whose it is, not by - // where its Events come from (#114) — a Subscribed or Linked Calendar - // someone else owns groups with the shared ones, since its Subscription/ - // Connection controls aren't the viewer's in any case. - const myCalendars = calendars.filter( - (calendar) => - canManageCalendar(calendar) && !calendar.sourceUrl && !isLinkedCalendar(calendar), - ); - const subscribedCalendars = calendars.filter( - (calendar) => canManageCalendar(calendar) && Boolean(calendar.sourceUrl), - ); - // Linked Calendars group under one heading per Connection rather than - // beside every other owned Calendar (#286) — the sidebar's only way to - // show "these came from that Google account". - // - // Keyed on the Connection's id, never on the account Email. The Email is a - // denormalized display field that only the *list* endpoint populates - // (attachConnectionEmails); every single-Calendar response omits it. Keying - // on it meant that refreshing a Linked Calendar — which replaces its store - // row with the single-Calendar GET's response — dropped the Calendar into an - // "Unknown account" heading until an unrelated list fetch happened to put - // the Email back. The id is on every response that carries a Source at all, - // so the grouping no longer depends on which endpoint a Calendar last came - // from. - // A Linked Calendar with no connectionId should not be reachable — the - // server sets it on every response carrying a Connection Source — but it - // groups under UNRESOLVED_CONNECTION rather than being dropped. A Calendar - // vanishing from the sidebar entirely is a far worse failure than one - // sitting under an unnamed heading, and this is the sidebar's last line - // against a payload shape changing underneath it. - const linkedCalendarsByConnection = new Map(); - for (const calendar of calendars) { - if (!canManageCalendar(calendar) || !isLinkedCalendar(calendar)) continue; - const key = calendar.connectionId ?? UNRESOLVED_CONNECTION; - const group = linkedCalendarsByConnection.get(key) ?? []; - group.push(calendar); - linkedCalendarsByConnection.set(key, group); - } - - // The heading's label, resolved at render rather than carried on the rows. - // The Connections store is the durable source; a Calendar's own - // connectionAccountEmail stands in while that first fetch is still in - // flight, and "Unknown account" is now a genuine unresolved-Connection - // state rather than an artefact of which endpoint answered last. - function connectionLabel( - connectionId: number | typeof UNRESOLVED_CONNECTION, - group: Calendar[], - ): string { - return ( - (connectionId !== UNRESOLVED_CONNECTION - ? connections.find((connection) => connection.id === connectionId)?.accountEmail - : undefined) ?? - group.find((calendar) => calendar.connectionAccountEmail)?.connectionAccountEmail ?? - "Unknown account" - ); - } - const sharedCalendars = calendars.filter( - (calendar) => !canManageCalendar(calendar), - ); + // Grouped exactly as the Calendar Set membership dialog groups them + // (#302, ADR-0082) — see calendarGrouping.ts, the one place this logic + // lives. + const { myCalendars, subscribedCalendars, linkedByConnection: linkedCalendarsByConnection, sharedCalendars } = + groupCalendarsForSidebar(calendars); const deletingCalendar = calendars.find( (calendar) => calendar.id === deletingCalendarId, @@ -464,7 +408,7 @@ export function CalendarList() { Email (#286) — two connected accounts produce two separate headings, since each is its own Map entry. */} {Array.from(linkedCalendarsByConnection.entries()).map(([connectionId, group]) => { - const accountEmail = connectionLabel(connectionId, group); + const accountEmail = connectionGroupLabel(connectionId, group, connections); return (
diff --git a/src/lib/calendarGrouping.ts b/src/lib/calendarGrouping.ts new file mode 100644 index 0000000..4f09174 --- /dev/null +++ b/src/lib/calendarGrouping.ts @@ -0,0 +1,70 @@ +import { canManageCalendar, isLinkedCalendar, type Calendar } from "./calendar"; +import type { Connection } from "./connectionsApi"; + +// UNRESOLVED_CONNECTION is the grouping key for a Linked Calendar whose +// Connection id is missing from the payload — a state the server should +// never produce, kept so that such a Calendar still renders instead of +// disappearing. See connectionGroupLabel. +export const UNRESOLVED_CONNECTION = Symbol("unresolved connection"); + +export interface GroupedCalendars { + myCalendars: Calendar[]; + subscribedCalendars: Calendar[]; + linkedByConnection: Map; + sharedCalendars: Calendar[]; +} + +// groupCalendarsForSidebar buckets calendars the way the sidebar does +// (#114, #286): My calendars, Subscribed, one Linked group per Connection, +// and Shared with me. The one place this grouping lives, so the sidebar +// (CalendarList) and the Calendar Set membership dialog (#302, ADR-0082) +// can never drift apart — ADR-0082's "existing origin grouping" reuse. +// +// A Calendar shared with the viewer groups by whose it is, not by where its +// Events come from (#114) — a Subscribed or Linked Calendar someone else +// owns groups with the shared ones, since its Subscription/Connection +// controls aren't the viewer's in any case. +export function groupCalendarsForSidebar(calendars: Calendar[]): GroupedCalendars { + const myCalendars = calendars.filter( + (calendar) => canManageCalendar(calendar) && !calendar.sourceUrl && !isLinkedCalendar(calendar), + ); + const subscribedCalendars = calendars.filter( + (calendar) => canManageCalendar(calendar) && Boolean(calendar.sourceUrl), + ); + + // Keyed on the Connection's id, never on the account Email — see + // CalendarList's own history with this (#295, #286): the Email is a + // denormalized display field only the list endpoint populates. + const linkedByConnection = new Map(); + for (const calendar of calendars) { + if (!canManageCalendar(calendar) || !isLinkedCalendar(calendar)) continue; + const key = calendar.connectionId ?? UNRESOLVED_CONNECTION; + const group = linkedByConnection.get(key) ?? []; + group.push(calendar); + linkedByConnection.set(key, group); + } + + const sharedCalendars = calendars.filter((calendar) => !canManageCalendar(calendar)); + + return { myCalendars, subscribedCalendars, linkedByConnection, sharedCalendars }; +} + +// connectionGroupLabel resolves a linkedByConnection entry's heading label, +// at render rather than carried on the rows. The Connections store is the +// durable source; a Calendar's own connectionAccountEmail stands in while +// that first fetch is still in flight, and "Unknown account" is a genuine +// unresolved-Connection state rather than an artefact of which endpoint +// answered last. +export function connectionGroupLabel( + connectionId: number | typeof UNRESOLVED_CONNECTION, + group: Calendar[], + connections: Connection[], +): string { + return ( + (connectionId !== UNRESOLVED_CONNECTION + ? connections.find((connection) => connection.id === connectionId)?.accountEmail + : undefined) ?? + group.find((calendar) => calendar.connectionAccountEmail)?.connectionAccountEmail ?? + "Unknown account" + ); +} diff --git a/src/lib/calendarSetsApi.ts b/src/lib/calendarSetsApi.ts index cadaeff..faf8a59 100644 --- a/src/lib/calendarSetsApi.ts +++ b/src/lib/calendarSetsApi.ts @@ -1,15 +1,18 @@ import { authedFetch, errorFromResponse } from "./apiClient"; import { workspaceHeaders } from "./workspaceHeaders"; -// Calendar Sets (#301, ADR-0082): a named, private selection of the active -// Workspace's Calendars belonging to one User. Scoped to the currently -// active Workspace via workspaceHeaders, the same as every other -// Workspace-scoped call — no sharing mechanism, no Role, so unlike groupsApi -// there is no membership-management surface here yet. +// Calendar Sets (#301, #302, ADR-0082): a named, private selection of the +// active Workspace's Calendars belonging to one User. Scoped to the +// currently active Workspace via workspaceHeaders, the same as every other +// Workspace-scoped call. export interface CalendarSet { id: number; name: string; + // calendarIds is this Set's membership, embedded on every List response + // (ADR-0082's REST surface) rather than a separate members endpoint — + // the membership dialog needs it the moment it has the Set itself. + calendarIds: string[]; } export const calendarSetsApi = { @@ -60,4 +63,25 @@ export const calendarSetsApi = { }); if (!response.ok) throw await errorFromResponse(response); }, + + // Puts calendarId into id's membership (#302). PUT, not POST: re-adding an + // already-member Calendar is a no-op, not a conflict. + async addCalendar(accessToken: string, id: number, calendarId: string): Promise { + const response = await authedFetch(accessToken, `/api/calendar-sets/${id}/calendars/${calendarId}`, { + method: "PUT", + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + }, + + // Takes calendarId out of id's membership (#302). + async removeCalendar(accessToken: string, id: number, calendarId: string): Promise { + const response = await authedFetch(accessToken, `/api/calendar-sets/${id}/calendars/${calendarId}`, { + method: "DELETE", + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + }, }; diff --git a/src/lib/calendarSetsStore.ts b/src/lib/calendarSetsStore.ts index 5c6d0bd..ba69e0f 100644 --- a/src/lib/calendarSetsStore.ts +++ b/src/lib/calendarSetsStore.ts @@ -2,15 +2,20 @@ import { create } from "zustand"; import { useAuthStore } from "./authStore"; import { type CalendarSet, calendarSetsApi } from "./calendarSetsApi"; -// The Calendar Sets Settings Section's data source (#301): every Calendar -// Set the caller owns in the active Workspace. Mirrors groupsStore's shape, -// minus membership management — a Set's membership is #302's. +// The Calendar Sets Settings Section's data source (#301, #302): every +// Calendar Set the caller owns in the active Workspace, each carrying its +// own membership. Mirrors groupsStore's shape, except membership rides +// along on the Set itself (calendarSet.calendarIds) rather than a +// separate membersByGroupId map — ADR-0082's List embeds it, so there is +// nothing to fetch on demand the way GroupMembersDialog does. interface CalendarSetsState { calendarSets: CalendarSet[]; fetchCalendarSets: () => Promise; createCalendarSet: (name: string) => Promise; renameCalendarSet: (id: number, name: string) => Promise; deleteCalendarSet: (id: number) => Promise; + addCalendarToSet: (id: number, calendarId: string) => Promise; + removeCalendarFromSet: (id: number, calendarId: string) => Promise; } function requireAccessToken(): string { @@ -42,4 +47,27 @@ export const useCalendarSetsStore = create((set, get) => ({ await calendarSetsApi.remove(requireAccessToken(), id); set({ calendarSets: get().calendarSets.filter((s) => s.id !== id) }); }, + + // Both immediate-effect, no save step (#302): the toggle in the + // membership dialog awaits this directly, so a failure surfaces inline + // rather than needing a rollback. + addCalendarToSet: async (id, calendarId) => { + await calendarSetsApi.addCalendar(requireAccessToken(), id, calendarId); + set({ + calendarSets: get().calendarSets.map((s) => + s.id === id && !s.calendarIds.includes(calendarId) + ? { ...s, calendarIds: [...s.calendarIds, calendarId] } + : s, + ), + }); + }, + + removeCalendarFromSet: async (id, calendarId) => { + await calendarSetsApi.removeCalendar(requireAccessToken(), id, calendarId); + set({ + calendarSets: get().calendarSets.map((s) => + s.id === id ? { ...s, calendarIds: s.calendarIds.filter((c) => c !== calendarId) } : s, + ), + }); + }, })); diff --git a/src/settings/CalendarSetMembershipDialog.tsx b/src/settings/CalendarSetMembershipDialog.tsx new file mode 100644 index 0000000..48925e2 --- /dev/null +++ b/src/settings/CalendarSetMembershipDialog.tsx @@ -0,0 +1,125 @@ +import { useState } from "react"; +import { Dialog } from "@base-ui/react/dialog"; +import { buttonClasses } from "../components/ui/buttonClasses"; +import { Checkbox } from "../components/ui/Checkbox"; +import { useCalendarsStore } from "../lib/calendarsStore"; +import { useConnectionsStore } from "../lib/connectionsStore"; +import { useCalendarSetsStore } from "../lib/calendarSetsStore"; +import { connectionGroupLabel, groupCalendarsForSidebar } from "../lib/calendarGrouping"; +import { errorMessage } from "../lib/errorMessage"; +import type { CalendarSet } from "../lib/calendarSetsApi"; +import type { Calendar } from "../lib/calendar"; + +interface CalendarSetMembershipDialogProps { + calendarSet: CalendarSet; + onClose: () => void; +} + +// The per-Set membership editor (#302, ADR-0082): every Calendar of the +// active Workspace, grouped exactly as the sidebar groups them (My +// calendars, Subscribed, one heading per Connection, Shared with me — +// calendarGrouping.ts), toggled in or out of this Set one at a time with +// immediate effect and no save step. Mirrors GroupMembersDialog's shape +// (#167), except there's nothing to fetch on demand: membership rides +// along on every calendarSetsStore Set already (ADR-0082's List). +export function CalendarSetMembershipDialog({ calendarSet, onClose }: CalendarSetMembershipDialogProps) { + const calendars = useCalendarsStore((state) => state.calendars); + const connections = useConnectionsStore((state) => state.connections); + const calendarSets = useCalendarSetsStore((state) => state.calendarSets); + const addCalendarToSet = useCalendarSetsStore((state) => state.addCalendarToSet); + const removeCalendarFromSet = useCalendarSetsStore((state) => state.removeCalendarFromSet); + + const [busyCalendarId, setBusyCalendarId] = useState(null); + const [actionError, setActionError] = useState(null); + + // Reads the live Set from the store rather than the calendarSet prop, so + // a toggle made from this same dialog reflects immediately. + const current = calendarSets.find((s) => s.id === calendarSet.id) ?? calendarSet; + const memberIds = new Set(current.calendarIds); + + const { myCalendars, subscribedCalendars, linkedByConnection, sharedCalendars } = groupCalendarsForSidebar(calendars); + + async function handleToggle(calendar: Calendar, isMember: boolean) { + setBusyCalendarId(calendar.id); + setActionError(null); + try { + if (isMember) { + await removeCalendarFromSet(calendarSet.id, calendar.id); + } else { + await addCalendarToSet(calendarSet.id, calendar.id); + } + } catch (err) { + setActionError(errorMessage(err)); + } finally { + setBusyCalendarId(null); + } + } + + function renderRow(calendar: Calendar) { + const isMember = memberIds.has(calendar.id); + return ( +
  • + {calendar.name} + handleToggle(calendar, isMember)} + disabled={busyCalendarId === calendar.id} + aria-label={ + isMember + ? `Remove ${calendar.name} from ${calendarSet.name}` + : `Add ${calendar.name} to ${calendarSet.name}` + } + /> +
  • + ); + } + + function renderGroup(label: string, list: Calendar[]) { + if (list.length === 0) return null; + return ( +
    +

    {label}

    +
      {list.map(renderRow)}
    +
    + ); + } + + return ( + !open && onClose()}> + + + + {calendarSet.name} + + Choose which of this workspace's calendars belong to this set. + + + {actionError && ( +

    + {actionError} +

    + )} + +
    + {renderGroup("My calendars", myCalendars)} + {renderGroup("Subscribed calendars", subscribedCalendars)} + {Array.from(linkedByConnection.entries()).map(([connectionId, group]) => + renderGroup(connectionGroupLabel(connectionId, group, connections), group), + )} + {renderGroup("Shared with me", sharedCalendars)} +
    + + {calendars.length === 0 && ( +

    No calendars in this workspace yet.

    + )} + +
    + + Done + +
    +
    +
    +
    + ); +} diff --git a/src/settings/CalendarSetsSection.tsx b/src/settings/CalendarSetsSection.tsx index 9d31d7d..3338980 100644 --- a/src/settings/CalendarSetsSection.tsx +++ b/src/settings/CalendarSetsSection.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Check, Pencil, Plus, Trash2, X } from "lucide-react"; +import { Calendar as CalendarIcon, Check, Pencil, Plus, Trash2, X } from "lucide-react"; import { Button } from "../components/ui/Button"; import { IconButton } from "../components/ui/IconButton"; import { Input } from "../components/ui/Input"; @@ -9,6 +9,7 @@ import { useWorkspacesStore } from "../lib/workspacesStore"; import { useCalendarSetsStore } from "../lib/calendarSetsStore"; import type { CalendarSet } from "../lib/calendarSetsApi"; import { DeleteCalendarSetDialog } from "./DeleteCalendarSetDialog"; +import { CalendarSetMembershipDialog } from "./CalendarSetMembershipDialog"; // The Calendar Sets Settings Section (#301, ADR-0082): create, rename and // delete the caller's own Calendar Sets — a named, private selection of the @@ -30,6 +31,7 @@ export function CalendarSetsSection() { const [editingId, setEditingId] = useState(null); const [editingName, setEditingName] = useState(""); const [deleteTarget, setDeleteTarget] = useState(null); + const [membersTarget, setMembersTarget] = useState(null); const { isSubmitting: isCreating, error: createError, run: runCreate } = useAsyncAction(); const { isSubmitting: isRenaming, error: renameError, setError: setRenameError, run: runRename } = useAsyncAction(); @@ -132,6 +134,12 @@ export function CalendarSetsSection() { ) : ( <> + setMembersTarget(calendarSet)} + aria-label={`Manage ${calendarSet.name} calendars`} + > + + startEditing(calendarSet)} aria-label={`Rename ${calendarSet.name}`}> @@ -153,6 +161,9 @@ export function CalendarSetsSection() { {deleteTarget && setDeleteTarget(null)} />} + {membersTarget && ( + setMembersTarget(null)} /> + )} ); } From e8c4ec6881f68255d6fa3079598f438ffe37eed6 Mon Sep 17 00:00:00 2001 From: XiovV Date: Fri, 11 Sep 2026 11:46:12 +0200 Subject: [PATCH 07/33] Selecting a Calendar Set narrows the sidebar and the grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single pure function, inScopeCalendars (src/lib/calendarSetScope.ts), answers which Calendars exist right now: every Calendar with no Active Calendar Set, only its members with one active, and nothing for an empty Set. The sidebar filters through it before grouping, so an out-of-Set Calendar is absent rather than unchecked, and a heading left with no in-Set Calendars hides itself — while checkedCalendarIds stays completely untouched, so a Calendar's toggle value survives a Set switch and back. The grid composes over the same rule: a new useVisibleCalendarIds hook intersects in-scope Calendars with checked ones, feeding both useVisibleOccurrences (Day/Week/Month) and the Year overview, so neither grid needs to know Sets exist. The Active Calendar Set lives in shellStore as activeCalendarSetId, alongside selectedDate and activeView, with null meaning "All calendars" — the absence of a Set rather than a row of its own. A top-bar CalendarSetSwitcher lists it plus the caller's own Sets. Built on Select for now, matching WorkspaceSwitcher; #304 upgrades it to the Menu primitive once it needs a divider and a "Manage sets" entry, and adds the zero-Set, reset-on-reload/Workspace-switch, and deleted-while-active handling this ticket deliberately leaves out. Closes #303 --- src/calendar-grid/YearGrid.tsx | 9 +- src/components/layout/CalendarList.test.tsx | 106 +++++++++++++++++- src/components/layout/CalendarList.tsx | 80 ++++++++----- src/components/layout/CalendarSetSwitcher.tsx | 51 +++++++++ src/components/layout/TopBar.tsx | 2 + src/hooks/useVisibleCalendarIds.ts | 25 +++++ src/hooks/useVisibleOccurrences.ts | 17 +-- src/lib/calendarSetScope.test.ts | 45 ++++++++ src/lib/calendarSetScope.ts | 22 ++++ src/lib/calendarSetsStore.ts | 14 +++ src/lib/shellStore.ts | 10 ++ 11 files changed, 341 insertions(+), 40 deletions(-) create mode 100644 src/components/layout/CalendarSetSwitcher.tsx create mode 100644 src/hooks/useVisibleCalendarIds.ts create mode 100644 src/lib/calendarSetScope.test.ts create mode 100644 src/lib/calendarSetScope.ts diff --git a/src/calendar-grid/YearGrid.tsx b/src/calendar-grid/YearGrid.tsx index feecd03..f618fc6 100644 --- a/src/calendar-grid/YearGrid.tsx +++ b/src/calendar-grid/YearGrid.tsx @@ -2,17 +2,18 @@ import { useShellStore } from "../lib/shellStore"; import { useEventsStore } from "../lib/eventsStore"; import { expandOccurrences } from "../lib/expandOccurrences"; import { buildDaysWithEvents, buildYearMonths } from "../lib/yearGrid"; +import { useVisibleCalendarIds } from "../hooks/useVisibleCalendarIds"; import { MiniMonth } from "./MiniMonth"; /** * The read-only Year overview: twelve Mini-months in a 4×3 grid for the Selected * date's year. Expands the visible Events into Occurrences across the whole year, - * then reduces them (checked Calendars only) to a set of day keys that drive each - * Mini-month's Event-presence dots. + * then reduces them (#303: Calendars in the Active Calendar Set and checked) to + * a set of day keys that drive each Mini-month's Event-presence dots. */ export function YearGrid() { const selectedDate = useShellStore((state) => state.selectedDate); - const checkedCalendarIds = useShellStore((state) => state.checkedCalendarIds); + const visibleCalendarIds = useVisibleCalendarIds(); const events = useEventsStore((state) => state.events); const months = buildYearMonths(selectedDate); @@ -22,7 +23,7 @@ export function YearGrid() { new Date(year, 0, 1), new Date(year + 1, 0, 1), ); - const daysWithEvents = buildDaysWithEvents(occurrences, checkedCalendarIds); + const daysWithEvents = buildDaysWithEvents(occurrences, visibleCalendarIds); return (
    diff --git a/src/components/layout/CalendarList.test.tsx b/src/components/layout/CalendarList.test.tsx index c25ea85..b2a9b29 100644 --- a/src/components/layout/CalendarList.test.tsx +++ b/src/components/layout/CalendarList.test.tsx @@ -26,6 +26,7 @@ const { useEventsStore } = await import("../../lib/eventsStore"); const { useShellStore } = await import("../../lib/shellStore"); const { useWorkspacesStore } = await import("../../lib/workspacesStore"); const { useConnectionsStore } = await import("../../lib/connectionsStore"); +const { useCalendarSetsStore } = await import("../../lib/calendarSetsStore"); const { connectionsApi } = await import("../../lib/connectionsApi"); const { calendarsApi } = await import("../../lib/calendarsApi"); const { CalendarList } = await import("./CalendarList"); @@ -53,8 +54,9 @@ beforeEach(() => { vi.clearAllMocks(); useAuthStore.setState({ accessToken: "token-123" }); useEventsStore.setState({ events: [] }); - useShellStore.setState({ checkedCalendarIds: new Set() }); + useShellStore.setState({ checkedCalendarIds: new Set(), activeCalendarSetId: null }); useConnectionsStore.setState({ connections: [] }); + useCalendarSetsStore.setState({ calendarSets: [] }); }); async function openMenu(calendarName: string) { @@ -291,3 +293,105 @@ describe("Linked Calendar Exposure toggle", () => { expect(useCalendarsStore.getState().calendars.find((c) => c.id === "cal-owned-linked")?.exposed).toBe(true); }); }); + +// #303, ADR-0082: the sidebar narrows to the Active Calendar Set before +// grouping, so an out-of-Set Calendar is absent rather than unchecked. +describe("Calendar Set narrowing", () => { + const workCalendar: Calendar = { + id: "cal-work", + name: "Work", + color: "#8E44ADFF", + access: "owner", + isOwner: true, + }; + const personalCalendar: Calendar = { + id: "cal-personal", + name: "Personal", + color: "#3498DBFF", + access: "owner", + isOwner: true, + }; + const feedCalendar: Calendar = { + id: "cal-feed", + name: "Holidays", + color: "#2ECC71FF", + access: "owner", + isOwner: true, + sourceUrl: "https://example.com/feed.ics", + }; + const workSet = { id: 1, name: "Work", calendarIds: ["cal-work"] }; + + it("renders an out-of-Set Calendar's row as absent, not merely unchecked", () => { + useCalendarsStore.setState({ calendars: [workCalendar, personalCalendar] }); + useCalendarSetsStore.setState({ calendarSets: [workSet] }); + useShellStore.setState({ + checkedCalendarIds: new Set(["cal-work", "cal-personal"]), + activeCalendarSetId: workSet.id, + }); + render(); + + expect(screen.getByText("Work")).toBeInTheDocument(); + expect(screen.queryByText("Personal")).not.toBeInTheDocument(); + expect(screen.queryByRole("checkbox", { name: "Personal" })).not.toBeInTheDocument(); + }); + + it("keeps a surviving heading, filtered to its in-Set Calendars", () => { + useCalendarsStore.setState({ calendars: [workCalendar, personalCalendar] }); + useCalendarSetsStore.setState({ calendarSets: [workSet] }); + useShellStore.setState({ + checkedCalendarIds: new Set(["cal-work", "cal-personal"]), + activeCalendarSetId: workSet.id, + }); + render(); + + expect(screen.getByText("My calendars")).toBeInTheDocument(); + }); + + it("hides a heading with no in-Set Calendars left under it", () => { + useCalendarsStore.setState({ calendars: [workCalendar, feedCalendar] }); + useCalendarSetsStore.setState({ calendarSets: [workSet] }); + useShellStore.setState({ + checkedCalendarIds: new Set(["cal-work", "cal-feed"]), + activeCalendarSetId: workSet.id, + }); + render(); + + expect(screen.getByText("My calendars")).toBeInTheDocument(); + expect(screen.queryByText("Subscribed calendars")).not.toBeInTheDocument(); + }); + + it("keeps every heading, even an empty one, while All calendars is active", () => { + useCalendarsStore.setState({ calendars: [workCalendar] }); + useCalendarSetsStore.setState({ calendarSets: [workSet] }); + useShellStore.setState({ + checkedCalendarIds: new Set(["cal-work"]), + activeCalendarSetId: null, + }); + render(); + + expect(screen.getByText("My calendars")).toBeInTheDocument(); + expect(screen.getByText("Subscribed calendars")).toBeInTheDocument(); + }); + + it("keeps a Calendar's toggle value across a Set switch and back", async () => { + useCalendarsStore.setState({ calendars: [workCalendar, personalCalendar] }); + useCalendarSetsStore.setState({ calendarSets: [workSet] }); + useShellStore.setState({ + checkedCalendarIds: new Set(["cal-work", "cal-personal"]), + activeCalendarSetId: null, + }); + render(); + + await userEvent.click(screen.getByRole("checkbox", { name: "Personal" })); + expect(useShellStore.getState().checkedCalendarIds.has("cal-personal")).toBe(false); + + // Switching into a Set that excludes "Personal" hides its row entirely; + // switching back to All calendars must not have touched its checked + // value while it was gone. + useShellStore.setState({ activeCalendarSetId: workSet.id }); + useShellStore.setState({ activeCalendarSetId: null }); + + expect(useShellStore.getState().checkedCalendarIds.has("cal-personal")).toBe(false); + expect(screen.getByRole("checkbox", { name: "Personal" })).toHaveAttribute("aria-checked", "false"); + }); +}); diff --git a/src/components/layout/CalendarList.tsx b/src/components/layout/CalendarList.tsx index 4afdaee..c1e0148 100644 --- a/src/components/layout/CalendarList.tsx +++ b/src/components/layout/CalendarList.tsx @@ -8,6 +8,8 @@ import { iconButtonClasses } from "../ui/iconButtonClasses"; import { canManageCalendar, isLinkedCalendar, shareCountTooltip, type Calendar } from "../../lib/calendar"; import { connectionGroupLabel, groupCalendarsForSidebar, UNRESOLVED_CONNECTION } from "../../lib/calendarGrouping"; import { resolveCalendarFill } from "../../lib/calendarColors"; +import { inScopeCalendars } from "../../lib/calendarSetScope"; +import { useActiveCalendarSet } from "../../lib/calendarSetsStore"; import { useAuthStore } from "../../lib/authStore"; import { useCalendarsStore } from "../../lib/calendarsStore"; import { useConnectionsStore } from "../../lib/connectionsStore"; @@ -103,11 +105,24 @@ export function CalendarList() { } | null>(null); const [isConfirmingExport, setIsConfirmingExport] = useState(false); + // Narrowed to the Active Calendar Set before grouping (#303, ADR-0082): + // an out-of-Set Calendar is absent from every heading below, not merely + // unchecked — CalendarToggle still reads off the untouched + // checkedCalendarIds, so a Calendar's toggle value survives switching + // Sets even while its row is gone. isSetActive gates whether an emptied + // heading below hides itself: "no Calendars at all" (today's ordinary + // empty state, headings stay so their Add buttons stay reachable) reads + // differently from "none left in this Set" (ADR-0082's "hidden rather + // than merely unchecked"). + const activeCalendarSet = useActiveCalendarSet(); + const isSetActive = activeCalendarSet !== null; + const visibleCalendars = inScopeCalendars(calendars, activeCalendarSet); + // Grouped exactly as the Calendar Set membership dialog groups them // (#302, ADR-0082) — see calendarGrouping.ts, the one place this logic // lives. const { myCalendars, subscribedCalendars, linkedByConnection: linkedCalendarsByConnection, sharedCalendars } = - groupCalendarsForSidebar(calendars); + groupCalendarsForSidebar(visibleCalendars); const deletingCalendar = calendars.find( (calendar) => calendar.id === deletingCalendarId, @@ -376,33 +391,44 @@ export function CalendarList() { return (
    -
    -

    - My calendars -

    - setIsCreateOpen(true)} - aria-label="Add calendar" - > - - -
    -
      {myCalendars.map(renderCalendarItem)}
    + {/* Hidden only once a Set has narrowed this heading's own group to + nothing (#303) — with no Set active, it stays put even at zero + Calendars, exactly as today, so its Add button stays reachable. */} + {(!isSetActive || myCalendars.length > 0) && ( + <> +
    +

    + My calendars +

    + setIsCreateOpen(true)} + aria-label="Add calendar" + > + + +
    +
      {myCalendars.map(renderCalendarItem)}
    + + )} -
    -

    - Subscribed calendars -

    - setIsSubscribeOpen(true)} - aria-label="Subscribe to a calendar" - > - - -
    -
      {subscribedCalendars.map(renderCalendarItem)}
    + {(!isSetActive || subscribedCalendars.length > 0) && ( + <> +
    +

    + Subscribed calendars +

    + setIsSubscribeOpen(true)} + aria-label="Subscribe to a calendar" + > + + +
    +
      {subscribedCalendars.map(renderCalendarItem)}
    + + )} {/* One heading per Connection, labelled with the connected account's Email (#286) — two connected accounts produce two separate diff --git a/src/components/layout/CalendarSetSwitcher.tsx b/src/components/layout/CalendarSetSwitcher.tsx new file mode 100644 index 0000000..c7bd6e9 --- /dev/null +++ b/src/components/layout/CalendarSetSwitcher.tsx @@ -0,0 +1,51 @@ +import { useEffect } from "react"; +import { Select } from "../ui/Select"; +import { useShellStore } from "../../lib/shellStore"; +import { useCalendarSetsStore } from "../../lib/calendarSetsStore"; +import { useWorkspacesStore } from "../../lib/workspacesStore"; + +// The Select's value space is strings; ALL_CALENDARS stands in for "no +// Active Calendar Set" — the absence of a Set, not a row of its own +// (ADR-0082) — since a Set's own id (a number) can never collide with it. +const ALL_CALENDARS = "all"; + +// The top-bar Calendar Set switcher (#303, ADR-0082): lists "All calendars" +// plus the caller's own Sets in the active Workspace, and selects between +// them. Built on Select for now, matching WorkspaceSwitcher — #304 upgrades +// it to the Menu primitive once it needs to carry a divider and a "Manage +// sets" entry, and adds the zero-Set/reset/deleted-while-active handling +// this ticket deliberately leaves out. +export function CalendarSetSwitcher() { + const activeWorkspaceId = useWorkspacesStore((state) => state.activeWorkspaceId); + const calendarSets = useCalendarSetsStore((state) => state.calendarSets); + const fetchCalendarSets = useCalendarSetsStore((state) => state.fetchCalendarSets); + const activeCalendarSetId = useShellStore((state) => state.activeCalendarSetId); + const setActiveCalendarSetId = useShellStore((state) => state.setActiveCalendarSetId); + + useEffect(() => { + if (activeWorkspaceId === null) return; + // A failure here costs the switcher its Set options and nothing else — + // "All calendars" still renders and still selects, same as CalendarList's + // own fetchConnections effect. + fetchCalendarSets().catch(() => {}); + }, [activeWorkspaceId, fetchCalendarSets]); + + if (activeWorkspaceId === null) return null; + + return ( + - setActiveCalendarSetId(value === ALL_CALENDARS ? null : Number(value)) - } - options={[ - { value: ALL_CALENDARS, label: "All calendars" }, - ...calendarSets.map((calendarSet) => ({ - value: String(calendarSet.id), - label: calendarSet.name, - })), - ]} - aria-label="Select calendar set" - /> + + + {activeCalendarSet?.name ?? "All calendars"} + + + + + + + setActiveCalendarSetId(value === ALL_CALENDARS ? null : Number(value)) + } + > + + + + + All calendars + + {calendarSets.map((calendarSet) => ( + + + + + {calendarSet.name} + + ))} + +
    + navigate("/settings/calendar-sets")} className={itemClasses}> + Manage sets + + + + + ); } diff --git a/src/lib/calendarSetsStore.test.ts b/src/lib/calendarSetsStore.test.ts new file mode 100644 index 0000000..451ddbd --- /dev/null +++ b/src/lib/calendarSetsStore.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./calendarSetsApi", async () => { + const actual = await vi.importActual("./calendarSetsApi"); + return { + ...actual, + calendarSetsApi: { ...actual.calendarSetsApi, remove: vi.fn() }, + }; +}); + +const { calendarSetsApi } = await import("./calendarSetsApi"); +const { useCalendarSetsStore } = await import("./calendarSetsStore"); +const { useShellStore } = await import("./shellStore"); +const { useAuthStore } = await import("./authStore"); + +const workSet = { id: 1, name: "Work", calendarIds: [] }; +const weekendSet = { id: 2, name: "Weekend", calendarIds: [] }; + +beforeEach(() => { + vi.clearAllMocks(); + useAuthStore.setState({ accessToken: "token-123" }); + useCalendarSetsStore.setState({ calendarSets: [workSet, weekendSet] }); + useShellStore.setState({ activeCalendarSetId: null }); +}); + +// #304, ADR-0082: deleting the Set currently active falls back to "All +// calendars" rather than leaving the caller looking through something that +// no longer exists. +describe("deleteCalendarSet", () => { + it("resets the Active Calendar Set to All calendars when the deleted Set was active", async () => { + vi.mocked(calendarSetsApi.remove).mockResolvedValue(undefined); + useShellStore.setState({ activeCalendarSetId: workSet.id }); + + await useCalendarSetsStore.getState().deleteCalendarSet(workSet.id); + + expect(useShellStore.getState().activeCalendarSetId).toBeNull(); + }); + + it("leaves the Active Calendar Set alone when a different Set is deleted", async () => { + vi.mocked(calendarSetsApi.remove).mockResolvedValue(undefined); + useShellStore.setState({ activeCalendarSetId: weekendSet.id }); + + await useCalendarSetsStore.getState().deleteCalendarSet(workSet.id); + + expect(useShellStore.getState().activeCalendarSetId).toBe(weekendSet.id); + }); +}); diff --git a/src/lib/calendarSetsStore.ts b/src/lib/calendarSetsStore.ts index 711a51a..721be7c 100644 --- a/src/lib/calendarSetsStore.ts +++ b/src/lib/calendarSetsStore.ts @@ -47,6 +47,15 @@ export const useCalendarSetsStore = create((set, get) => ({ deleteCalendarSet: async (id) => { await calendarSetsApi.remove(requireAccessToken(), id); set({ calendarSets: get().calendarSets.filter((s) => s.id !== id) }); + // Deleting the Set currently being looked through falls back to "All + // calendars" rather than leaving the caller looking through something + // that no longer exists (#304). A stale id already degrades to "All + // calendars" wherever useActiveCalendarSet resolves it, but the + // switcher's own selection needs activeCalendarSetId itself cleared to + // render that fallback rather than nothing checked at all. + if (useShellStore.getState().activeCalendarSetId === id) { + useShellStore.getState().setActiveCalendarSetId(null); + } }, // Both immediate-effect, no save step (#302): the toggle in the diff --git a/src/lib/shellStore.test.ts b/src/lib/shellStore.test.ts index fea4889..f32f408 100644 --- a/src/lib/shellStore.test.ts +++ b/src/lib/shellStore.test.ts @@ -5,6 +5,12 @@ describe("useShellStore", () => { it("defaults activeView to week", () => { expect(useShellStore.getState().activeView).toBe("week"); }); + + // #304, ADR-0082: no Preference seeds this, unlike Default view — every + // reload starts on "All calendars" since the store itself re-initializes. + it("defaults activeCalendarSetId to null (All calendars)", () => { + expect(useShellStore.getState().activeCalendarSetId).toBeNull(); + }); }); describe("reconcileCheckedCalendarIds", () => { diff --git a/src/lib/workspacesStore.test.ts b/src/lib/workspacesStore.test.ts new file mode 100644 index 0000000..d5f4a8d --- /dev/null +++ b/src/lib/workspacesStore.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./workspacesApi", async () => { + const actual = await vi.importActual("./workspacesApi"); + return { + ...actual, + workspacesApi: { list: vi.fn() }, + }; +}); + +const { useWorkspacesStore } = await import("./workspacesStore"); +const { useShellStore } = await import("./shellStore"); + +const workspaceA = { id: 1, name: "Acme", defaultSharePrivacy: "private" as const }; +const workspaceB = { id: 2, name: "Beta", defaultSharePrivacy: "private" as const }; + +beforeEach(() => { + vi.clearAllMocks(); + useWorkspacesStore.setState({ workspaces: [workspaceA, workspaceB], activeWorkspaceId: workspaceA.id }); + useShellStore.setState({ activeCalendarSetId: null }); +}); + +// #304, ADR-0082: a Calendar Set belongs to one Workspace, so its id is +// meaningless — or coincidentally means something else — in any other one. +describe("setActiveWorkspaceId", () => { + it("switches the active Workspace", () => { + useWorkspacesStore.getState().setActiveWorkspaceId(workspaceB.id); + + expect(useWorkspacesStore.getState().activeWorkspaceId).toBe(workspaceB.id); + }); + + it("ignores an id that isn't in the caller's Workspace list", () => { + useWorkspacesStore.getState().setActiveWorkspaceId(999); + + expect(useWorkspacesStore.getState().activeWorkspaceId).toBe(workspaceA.id); + }); + + it("resets the Active Calendar Set to All calendars", () => { + useShellStore.setState({ activeCalendarSetId: 7 }); + + useWorkspacesStore.getState().setActiveWorkspaceId(workspaceB.id); + + expect(useShellStore.getState().activeCalendarSetId).toBeNull(); + }); + + it("leaves the Active Calendar Set alone when the switch is refused", () => { + useShellStore.setState({ activeCalendarSetId: 7 }); + + useWorkspacesStore.getState().setActiveWorkspaceId(999); + + expect(useShellStore.getState().activeCalendarSetId).toBe(7); + }); +}); diff --git a/src/lib/workspacesStore.ts b/src/lib/workspacesStore.ts index fa954e8..931b919 100644 --- a/src/lib/workspacesStore.ts +++ b/src/lib/workspacesStore.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import { useAuthStore } from "./authStore"; +import { useShellStore } from "./shellStore"; import { workspacesApi, type Workspace } from "./workspacesApi"; interface WorkspacesState { @@ -43,5 +44,10 @@ export const useWorkspacesStore = create((set, get) => ({ setActiveWorkspaceId: (id) => { if (!get().workspaces.some((workspace) => workspace.id === id)) return; set({ activeWorkspaceId: id }); + // A Calendar Set belongs to one Workspace (ADR-0082): its id could + // coincide with an unrelated Set's in the one just entered, or point at + // nothing there at all. Every Workspace switch lands back on "All + // calendars" rather than risk looking through either (#304). + useShellStore.getState().setActiveCalendarSetId(null); }, })); From 5fcc74eb62ceaf9aac4ca90b2875a693ef803cdf Mon Sep 17 00:00:00 2001 From: XiovV Date: Fri, 11 Sep 2026 12:09:15 +0200 Subject: [PATCH 09/33] The Event modal's Calendar picker narrows to the Active Calendar Set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker's invariant — you can only create an Event on a Calendar you can currently see — already covered unchecked Calendars; the Active Calendar Set is a second way of not seeing one, so it narrows the picker too. checkedCalendars now composes inScopeCalendars over writableCalendars before the checked filter, so the default selection falls inside the Active Calendar Set by construction, with no new Set-awareness of its own. calendarPickerEmptyReason gains a fourth case, "outOfSet", firing only when the Active Calendar Set's own (already narrowed) membership is empty — distinct from "none", which means no Calendars exist anywhere. Its remedy is switching to All calendars, so it's excluded from the picker's "Create a calendar" button exactly like "hidden" already is. Inside a non-empty Set the existing hidden/unwritable precedence is unchanged, now proven by tests that pass a non-empty Set's membership instead of the full Calendar list — including a Set holding only read-only Calendars, which correctly falls through to "unwritable" rather than "outOfSet". calendar.ts itself stays free of any Calendar-Set import; the narrowing happens entirely in EventModal, the only caller. The eventForOptions rescue (an edited Event's own Calendar surviving in the picker even when hidden) stays keyed off the unscoped Calendar list deliberately, so an Event's Calendar falling out of the Active Set never strands its own edit. Closes #305 --- src/calendar-grid/EventModal.test.tsx | 72 +++++++++++++++++++++++++++ src/calendar-grid/EventModal.tsx | 40 ++++++++++++--- src/lib/calendar.test.ts | 38 ++++++++++++-- src/lib/calendar.ts | 31 ++++++++---- 4 files changed, 160 insertions(+), 21 deletions(-) diff --git a/src/calendar-grid/EventModal.test.tsx b/src/calendar-grid/EventModal.test.tsx index b782a97..94b5971 100644 --- a/src/calendar-grid/EventModal.test.tsx +++ b/src/calendar-grid/EventModal.test.tsx @@ -56,6 +56,7 @@ const { groupsApi } = await import("../lib/groupsApi"); const { workspaceMembersApi } = await import("../lib/workspaceMembersApi"); const { useAuthStore } = await import("../lib/authStore"); const { useCalendarsStore } = await import("../lib/calendarsStore"); +const { useCalendarSetsStore } = await import("../lib/calendarSetsStore"); const { useEventsStore } = await import("../lib/eventsStore"); const { useShellStore } = await import("../lib/shellStore"); const { EventModal } = await import("./EventModal"); @@ -115,7 +116,9 @@ function seed(events: Event[], calendars: Calendar[] = [owned]) { useCalendarsStore.setState({ calendars }); useShellStore.setState({ checkedCalendarIds: new Set(calendars.map((c) => c.id)), + activeCalendarSetId: null, }); + useCalendarSetsStore.setState({ calendarSets: [] }); useEventsStore.setState({ events }); useAuthStore.setState({ status: "authenticated", @@ -297,6 +300,75 @@ describe("EventModal — creating a calendar from the empty state (#233)", () => }); }); +// #305, ADR-0082: a second way of not seeing a Calendar — the picker +// narrows to the Active Calendar Set on top of checked/writable, and its +// own empty state ("outOfSet") never suggests creating a new Calendar, +// since the remedy is switching Sets, not owning more Calendars. +describe("EventModal — the Calendar picker narrows to the Active Calendar Set (#305)", () => { + const teamRoom: Calendar = { + id: "cal-3", + name: "Team room", + color: "#2ECC71FF", + access: "owner", + isOwner: true, + }; + + it("explains an empty Active Calendar Set as 'outOfSet', with no Create-a-calendar remedy", () => { + seed([], [owned]); + useCalendarSetsStore.setState({ + calendarSets: [{ id: 1, name: "Empty set", calendarIds: [] }], + }); + useShellStore.setState({ activeCalendarSetId: 1 }); + renderCreate(); + + expect( + screen.getByText( + "This calendar set has nothing you can add events to. Switch to All calendars to see the rest of your calendars.", + ), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Create a calendar" }), + ).not.toBeInTheDocument(); + }); + + it("reports 'unwritable', not 'outOfSet', when a non-empty Set holds only a read-only Calendar", () => { + seed([], [viewerOnly]); + useCalendarSetsStore.setState({ + calendarSets: [{ id: 1, name: "Shared", calendarIds: ["cal-2"] }], + }); + useShellStore.setState({ + activeCalendarSetId: 1, + checkedCalendarIds: new Set(["cal-2"]), + }); + renderCreate(); + + expect( + screen.getByText( + "You don't have a calendar you can add events to — everything you can see is shared with view-only access.", + ), + ).toBeInTheDocument(); + }); + + it("offers only the Active Calendar Set's own members, defaulting the selection inside it", async () => { + seed([], [owned, teamRoom]); + useCalendarSetsStore.setState({ + calendarSets: [{ id: 1, name: "Work", calendarIds: ["cal-3"] }], + }); + useShellStore.setState({ + activeCalendarSetId: 1, + checkedCalendarIds: new Set(["cal-1", "cal-3"]), + }); + renderCreate(); + + const picker = screen.getByRole("combobox", { name: "Calendar" }); + expect(picker).toHaveTextContent("Team room"); + + await userEvent.click(picker); + expect(await screen.findByRole("option", { name: "Team room" })).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: "Personal" })).not.toBeInTheDocument(); + }); +}); + describe("EventModal — editing a non-recurring Event", () => { it("updates the Event without asking for a scope", async () => { const event = makeEvent(); diff --git a/src/calendar-grid/EventModal.tsx b/src/calendar-grid/EventModal.tsx index 122d0f6..25f5305 100644 --- a/src/calendar-grid/EventModal.tsx +++ b/src/calendar-grid/EventModal.tsx @@ -71,6 +71,8 @@ import { type CalendarPickerEmptyReason, } from "../lib/calendar"; import { resolveOccurrenceColor, toOpaqueHex } from "../lib/calendarColors"; +import { inScopeCalendars } from "../lib/calendarSetScope"; +import { useActiveCalendarSet } from "../lib/calendarSetsStore"; import { ColorSwatchPicker } from "../components/layout/ColorSwatchPicker"; import { CalendarModal } from "../components/layout/CalendarModal"; import { useShellStore } from "../lib/shellStore"; @@ -112,6 +114,8 @@ const CALENDAR_EMPTY_STATE_COPY: Record = { "Your calendars are all hidden. Show one in the sidebar to add events to it.", unwritable: "You don't have a calendar you can add events to — everything you can see is shared with view-only access.", + outOfSet: + "This calendar set has nothing you can add events to. Switch to All calendars to see the rest of your calendars.", }; type EventModalProps = @@ -271,6 +275,7 @@ export function EventModal(props: EventModalProps) { const { mode, onClose } = props; const checkedCalendarIds = useShellStore((state) => state.checkedCalendarIds); + const activeCalendarSet = useActiveCalendarSet(); const events = useEventsStore((state) => state.events); const fetchEvents = useEventsStore((state) => state.fetchEvents); const addEvent = useEventsStore((state) => state.addEvent); @@ -302,10 +307,17 @@ export function EventModal(props: EventModalProps) { // (#111, ADR-0034) — a Subscribed Calendar (Refresh is its only // legitimate writer, #84, ADR-0032) and a Calendar the caller has no more // than Viewer Access to both never appear as Calendar picker options, - // whether checked or not. + // whether checked or not. Deliberately unscoped by the Active Calendar + // Set: the eventForOptions rescue just below still needs to find an + // edited Event's own Calendar here even if it has since fallen out of + // the Set, the same grace it already gets for falling out of checked. const writableCalendars = calendars.filter((c) => canWriteCalendarEvents(c)); + // The picker itself narrows to in-Set, checked, writable (#305, + // ADR-0082) — a second way of not seeing a Calendar, exactly like + // unchecked, so defaultCalendarId below falls inside the Active Calendar + // Set by construction rather than needing its own Set-awareness. const checkedCalendars = getCheckedCalendars( - writableCalendars, + inScopeCalendars(writableCalendars, activeCalendarSet), checkedCalendarIds, ); // An event's calendar may have been unchecked (hidden) since it was created — @@ -369,13 +381,22 @@ export function EventModal(props: EventModalProps) { const showAttachmentUploader = mode === "edit" && calendarHasOtherRecipients(editedCalendar); - // #174: when no Calendar is a valid write target, the picker's empty - // state names why instead of rendering an empty dropdown next to a + // #174, #305: when no Calendar is a valid write target, the picker's + // empty state names why instead of rendering an empty dropdown next to a // silently disabled Save — the remedy differs per reason (create one, - // show one, or neither applies). + // show one, switch to All calendars, or neither applies). calendars is + // narrowed to the Active Calendar Set's own membership here, exactly as + // ADR-0082 asks — a Set that's itself empty must report "outOfSet" + // regardless of what the caller owns outside it, which is why this + // passes the narrowed list rather than the full one calendarOptions + // above deliberately doesn't (see its own comment). const calendarEmptyReason = calendarOptions.length === 0 - ? calendarPickerEmptyReason(calendars, checkedCalendarIds) + ? calendarPickerEmptyReason( + inScopeCalendars(calendars, activeCalendarSet), + checkedCalendarIds, + activeCalendarSet !== null, + ) : undefined; // Whether the Calendar row is actually rendering its empty-state branch // (#193) — calendarEmptyReason alone is a bad proxy for this: it's @@ -1226,7 +1247,12 @@ export function EventModal(props: EventModalProps) {

    {CALENDAR_EMPTY_STATE_COPY[calendarEmptyReason]}

    - {calendarEmptyReason !== "hidden" && ( + {/* Neither "hidden" (go show one in the sidebar) nor + "outOfSet" (go switch to All calendars, #305) is + remedied by creating a new Calendar — offering this + button there would suggest a fix that leaves the + caller exactly as stuck as before. */} + {calendarEmptyReason !== "hidden" && calendarEmptyReason !== "outOfSet" && (
    } /> + + , + ); +} + async function openMenu(calendarName: string) { await userEvent.click(screen.getByRole("button", { name: `${calendarName} actions` })); } @@ -66,7 +82,7 @@ async function openMenu(calendarName: string) { describe("CalendarList row menu", () => { it("offers Share on a calendar the viewer owns", async () => { useCalendarsStore.setState({ calendars: [owned] }); - render(); + renderCalendarList(); await openMenu("Personal"); @@ -75,7 +91,7 @@ describe("CalendarList row menu", () => { it("omits Share entirely on a calendar the viewer only has Editor Access to", async () => { useCalendarsStore.setState({ calendars: [editorAccess] }); - render(); + renderCalendarList(); await openMenu("Team"); @@ -112,7 +128,7 @@ describe("Linked Calendar grouping", () => { it("shows one heading per Connection's account email", () => { useCalendarsStore.setState({ calendars: [owned, workLinked, personalLinked] }); - render(); + renderCalendarList(); expect(screen.getByText("work@gmail.com")).toBeInTheDocument(); expect(screen.getByText("personal@gmail.com")).toBeInTheDocument(); @@ -122,7 +138,7 @@ describe("Linked Calendar grouping", () => { it("keeps a Linked Calendar out of My calendars", () => { useCalendarsStore.setState({ calendars: [owned, workLinked] }); - render(); + renderCalendarList(); expect(screen.getByText("My calendars")).toBeInTheDocument(); // "Work" only ever renders under its Connection's own heading. @@ -135,7 +151,7 @@ describe("Linked Calendar grouping", () => { useWorkspacesStore.setState({ activeWorkspaceId: 7 }); vi.mocked(connectionsApi.listPickerCalendars).mockResolvedValue([]); useCalendarsStore.setState({ calendars: [{ ...workLinked, connectionId: 42 }] }); - render(); + renderCalendarList(); await userEvent.click( screen.getByRole("button", { name: "Choose calendars from work@gmail.com" }), @@ -161,7 +177,7 @@ describe("Linked Calendar grouping", () => { connectionAccountEmail: "bob@gmail.com", }; useCalendarsStore.setState({ calendars: [owned, sharedLinked] }); - render(); + renderCalendarList(); expect(screen.getByText("Shared with me")).toBeInTheDocument(); // No Connection heading is rendered — that grouping is the Owner's, and @@ -188,7 +204,7 @@ describe("Linked Calendar grouping", () => { // Exactly what calendarsApi.get returns: the Connection id, no Email. const refreshed: Calendar = { ...workLinked, connectionAccountEmail: undefined }; useCalendarsStore.setState({ calendars: [owned, refreshed] }); - render(); + renderCalendarList(); expect(screen.getByText("work@gmail.com")).toBeInTheDocument(); expect(screen.queryByText("Unknown account")).not.toBeInTheDocument(); @@ -207,7 +223,7 @@ describe("Linked Calendar grouping", () => { const sibling: Calendar = { ...workLinked, id: "cal-work-2", name: "Work Two" }; const refreshed: Calendar = { ...workLinked, connectionAccountEmail: undefined }; useCalendarsStore.setState({ calendars: [refreshed, sibling] }); - render(); + renderCalendarList(); expect(screen.getAllByText("work@gmail.com")).toHaveLength(1); expect(screen.queryByText("Unknown account")).not.toBeInTheDocument(); @@ -217,7 +233,7 @@ describe("Linked Calendar grouping", () => { // Subscribed one — and, like one, offers no per-Calendar Export. it("offers Refresh and not Export on a Linked Calendar", async () => { useCalendarsStore.setState({ calendars: [workLinked] }); - render(); + renderCalendarList(); await openMenu("Work"); @@ -255,7 +271,7 @@ describe("Linked Calendar Exposure toggle", () => { it("offers the toggle to the Linked Calendar's own Owner, reflecting the resolved default", async () => { useCalendarsStore.setState({ calendars: [ownedLinked] }); - render(); + renderCalendarList(); await openMenu("Work"); @@ -265,7 +281,7 @@ describe("Linked Calendar Exposure toggle", () => { it("also offers the toggle to an accessor it was Shared to, reflecting their own resolved default", async () => { useCalendarsStore.setState({ calendars: [owned, sharedLinked] }); - render(); + renderCalendarList(); await openMenu("Colleague's Google"); @@ -275,7 +291,7 @@ describe("Linked Calendar Exposure toggle", () => { it("omits the toggle on a Calendar that isn't Linked", async () => { useCalendarsStore.setState({ calendars: [owned] }); - render(); + renderCalendarList(); await openMenu("Personal"); @@ -284,7 +300,7 @@ describe("Linked Calendar Exposure toggle", () => { it("writes the caller's own choice when toggled", async () => { useCalendarsStore.setState({ calendars: [ownedLinked] }); - render(); + renderCalendarList(); await openMenu("Work"); await userEvent.click(await screen.findByRole("menuitemcheckbox", { name: "Show on my devices" })); @@ -328,7 +344,7 @@ describe("Calendar Set narrowing", () => { checkedCalendarIds: new Set(["cal-work", "cal-personal"]), activeCalendarSetId: workSet.id, }); - render(); + renderCalendarList(); expect(screen.getByText("Work")).toBeInTheDocument(); expect(screen.queryByText("Personal")).not.toBeInTheDocument(); @@ -342,7 +358,7 @@ describe("Calendar Set narrowing", () => { checkedCalendarIds: new Set(["cal-work", "cal-personal"]), activeCalendarSetId: workSet.id, }); - render(); + renderCalendarList(); expect(screen.getByText("My calendars")).toBeInTheDocument(); }); @@ -354,7 +370,7 @@ describe("Calendar Set narrowing", () => { checkedCalendarIds: new Set(["cal-work", "cal-feed"]), activeCalendarSetId: workSet.id, }); - render(); + renderCalendarList(); expect(screen.getByText("My calendars")).toBeInTheDocument(); expect(screen.queryByText("Subscribed calendars")).not.toBeInTheDocument(); @@ -367,7 +383,7 @@ describe("Calendar Set narrowing", () => { checkedCalendarIds: new Set(["cal-work"]), activeCalendarSetId: null, }); - render(); + renderCalendarList(); expect(screen.getByText("My calendars")).toBeInTheDocument(); expect(screen.getByText("Subscribed calendars")).toBeInTheDocument(); @@ -380,7 +396,7 @@ describe("Calendar Set narrowing", () => { checkedCalendarIds: new Set(["cal-work", "cal-personal"]), activeCalendarSetId: null, }); - render(); + renderCalendarList(); await userEvent.click(screen.getByRole("checkbox", { name: "Personal" })); expect(useShellStore.getState().checkedCalendarIds.has("cal-personal")).toBe(false); @@ -395,3 +411,57 @@ describe("Calendar Set narrowing", () => { expect(screen.getByRole("checkbox", { name: "Personal" })).toHaveAttribute("aria-checked", "false"); }); }); + +// #306, ADR-0082: an empty Active Calendar Set — created empty, or emptied by +// cascade when a Share was revoked or a Calendar deleted — names itself in +// the sidebar rather than rendering nothing, and offers both a route to +// manage Sets and a way back to "All calendars". The grid renders nothing in +// this state too, via the same inScopeCalendars answer CalendarList itself +// uses; that absence needs no message of its own. +describe("Empty Calendar Set", () => { + const emptySet = { id: 2, name: "Empty", calendarIds: [] }; + const workCalendar: Calendar = { + id: "cal-work", + name: "Work", + color: "#8E44ADFF", + access: "owner", + isOwner: true, + }; + + function activateEmptySet() { + useCalendarsStore.setState({ calendars: [workCalendar] }); + useCalendarSetsStore.setState({ calendarSets: [emptySet] }); + useShellStore.setState({ + checkedCalendarIds: new Set(["cal-work"]), + activeCalendarSetId: emptySet.id, + }); + } + + it("names the situation instead of rendering nothing", () => { + activateEmptySet(); + renderCalendarList(); + + expect(screen.getByText(`"${emptySet.name}" has no calendars in it.`)).toBeInTheDocument(); + expect(screen.queryByText("My calendars")).not.toBeInTheDocument(); + expect(screen.queryByText("Work")).not.toBeInTheDocument(); + }); + + it("offers a route to the Calendar Sets Settings Section", async () => { + activateEmptySet(); + renderCalendarList(); + + await userEvent.click(screen.getByRole("button", { name: "Manage sets" })); + + expect(await screen.findByText("Calendar sets destination")).toBeInTheDocument(); + }); + + it("offers a way back to All calendars", async () => { + activateEmptySet(); + renderCalendarList(); + + await userEvent.click(screen.getByRole("button", { name: "Back to All calendars" })); + + expect(useShellStore.getState().activeCalendarSetId).toBeNull(); + expect(await screen.findByText("My calendars")).toBeInTheDocument(); + }); +}); diff --git a/src/components/layout/CalendarList.tsx b/src/components/layout/CalendarList.tsx index c1e0148..8945a07 100644 --- a/src/components/layout/CalendarList.tsx +++ b/src/components/layout/CalendarList.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from "react"; import { formatDistanceToNow } from "date-fns"; import { Menu } from "@base-ui/react/menu"; -import { Check, MoreVertical, Plus, TriangleAlert, Users } from "lucide-react"; +import { Check, Layers, MoreVertical, Plus, TriangleAlert, Users } from "lucide-react"; +import { useNavigate } from "react-router"; import { CalendarPickerModal } from "../../settings/CalendarPickerModal"; +import { Button } from "../ui/Button"; import { IconButton } from "../ui/IconButton"; import { iconButtonClasses } from "../ui/iconButtonClasses"; import { canManageCalendar, isLinkedCalendar, shareCountTooltip, type Calendar } from "../../lib/calendar"; @@ -53,6 +55,7 @@ function subscriptionErrorReason(calendar: Calendar): string | undefined { } export function CalendarList() { + const navigate = useNavigate(); const calendars = useCalendarsStore((state) => state.calendars); const events = useEventsStore((state) => state.events); const accessToken = useAuthStore((state) => state.accessToken); @@ -60,6 +63,7 @@ export function CalendarList() { const toggleCalendarChecked = useShellStore( (state) => state.toggleCalendarChecked, ); + const setActiveCalendarSetId = useShellStore((state) => state.setActiveCalendarSetId); const refreshCalendar = useCalendarsStore((state) => state.refreshCalendar); const setCalendarExposure = useCalendarsStore((state) => state.setCalendarExposure); // Linked Calendars group by Connection, so the sidebar needs the @@ -117,6 +121,14 @@ export function CalendarList() { const activeCalendarSet = useActiveCalendarSet(); const isSetActive = activeCalendarSet !== null; const visibleCalendars = inScopeCalendars(calendars, activeCalendarSet); + // An empty Active Calendar Set is a legitimate state (#306, ADR-0082), + // reached either by creating one empty or by every member falling out via + // cascade (a revoked Share, a deleted Calendar). Named explicitly rather + // than falling through to the ordinary heading logic below, which would + // render nothing at all and read as a loading failure. The grid renders + // nothing here too, via the same inScopeCalendars answer — that absence is + // correct and needs no message of its own. + const isEmptySet = isSetActive && visibleCalendars.length === 0; // Grouped exactly as the Calendar Set membership dialog groups them // (#302, ADR-0082) — see calendarGrouping.ts, the one place this logic @@ -391,9 +403,35 @@ export function CalendarList() { return (
    + {isEmptySet && activeCalendarSet && ( +
    +
    + )} + {/* Hidden only once a Set has narrowed this heading's own group to nothing (#303) — with no Set active, it stays put even at zero - Calendars, exactly as today, so its Add button stays reachable. */} + Calendars, exactly as today, so its Add button stays reachable. + isEmptySet already implies myCalendars is empty here, so no + separate guard is needed for it. */} {(!isSetActive || myCalendars.length > 0) && ( <>
    From f7d3ce4cdc94af57781fee9a49a0d57179e13db1 Mon Sep 17 00:00:00 2001 From: XiovV Date: Fri, 11 Sep 2026 12:26:23 +0200 Subject: [PATCH 11/33] Add a Calendar to a Set from its sidebar row menu Each row's action menu gains an "Add to set" submenu listing the caller's Calendar Sets, marking membership and toggling it with immediate effect. Ungated by canManage/origin since membership carries no Role and grants no Access, so it works identically on owned, Subscribed, Linked and shared-in rows. Absent entirely at zero Sets. Fixes #307 --- src/components/layout/CalendarList.test.tsx | 124 ++++++++++++++++++++ src/components/layout/CalendarList.tsx | 89 +++++++++++++- 2 files changed, 211 insertions(+), 2 deletions(-) diff --git a/src/components/layout/CalendarList.test.tsx b/src/components/layout/CalendarList.test.tsx index b053ac7..de07b8c 100644 --- a/src/components/layout/CalendarList.test.tsx +++ b/src/components/layout/CalendarList.test.tsx @@ -20,6 +20,13 @@ vi.mock("../../lib/icsApi", () => ({ icsApi: { downloadCalendar: vi.fn(), calendarOversizedAttachments: vi.fn() }, })); vi.mock("../../lib/toast", () => ({ toast: { error: vi.fn(), success: vi.fn() } })); +vi.mock("../../lib/calendarSetsApi", async () => { + const actual = await vi.importActual("../../lib/calendarSetsApi"); + return { + ...actual, + calendarSetsApi: { ...actual.calendarSetsApi, addCalendar: vi.fn(), removeCalendar: vi.fn() }, + }; +}); const { useAuthStore } = await import("../../lib/authStore"); const { useCalendarsStore } = await import("../../lib/calendarsStore"); @@ -30,6 +37,7 @@ const { useConnectionsStore } = await import("../../lib/connectionsStore"); const { useCalendarSetsStore } = await import("../../lib/calendarSetsStore"); const { connectionsApi } = await import("../../lib/connectionsApi"); const { calendarsApi } = await import("../../lib/calendarsApi"); +const { calendarSetsApi } = await import("../../lib/calendarSetsApi"); const { CalendarList } = await import("./CalendarList"); const owned: Calendar = { @@ -465,3 +473,119 @@ describe("Empty Calendar Set", () => { expect(await screen.findByText("My calendars")).toBeInTheDocument(); }); }); + +// #307, ADR-0082: a Calendar can be put into a Calendar Set straight from its +// sidebar row menu, without opening Settings — the same immediate-effect +// toggle CalendarSetMembershipDialog offers, reached from where the intent +// actually arises. +describe("Add to set", () => { + const workSet = { id: 1, name: "Work", calendarIds: ["cal-1"] }; + const weekendSet = { id: 2, name: "Weekend", calendarIds: [] }; + const linked: Calendar = { + id: "cal-linked", + name: "Google Work", + color: "#8E44ADFF", + access: "viewer", + isOwner: true, + sourceKind: "connection", + connectionId: 1, + connectionAccountEmail: "work@gmail.com", + }; + + async function openAddToSet(calendarName: string) { + await openMenu(calendarName); + await userEvent.click(await screen.findByRole("menuitem", { name: "Add to set" })); + } + + it("is absent with zero Sets rather than an empty submenu", async () => { + useCalendarsStore.setState({ calendars: [owned] }); + useCalendarSetsStore.setState({ calendarSets: [] }); + renderCalendarList(); + + await openMenu("Personal"); + + expect(await screen.findByRole("menuitem", { name: "Edit" })).toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: "Add to set" })).not.toBeInTheDocument(); + }); + + it("lists the caller's Sets, marking the ones this Calendar already belongs to", async () => { + useCalendarsStore.setState({ calendars: [owned] }); + useCalendarSetsStore.setState({ calendarSets: [workSet, weekendSet] }); + renderCalendarList(); + + await openAddToSet("Personal"); + + expect(await screen.findByRole("menuitemcheckbox", { name: "Remove Personal from Work" })).toHaveAttribute( + "aria-checked", + "true", + ); + expect(screen.getByRole("menuitemcheckbox", { name: "Add Personal to Weekend" })).toHaveAttribute( + "aria-checked", + "false", + ); + }); + + it("adds membership immediately when an unmarked Set is selected", async () => { + useCalendarsStore.setState({ calendars: [owned] }); + useCalendarSetsStore.setState({ calendarSets: [weekendSet] }); + vi.mocked(calendarSetsApi.addCalendar).mockResolvedValue(undefined); + renderCalendarList(); + + await openAddToSet("Personal"); + await userEvent.click(await screen.findByRole("menuitemcheckbox", { name: "Add Personal to Weekend" })); + + expect(calendarSetsApi.addCalendar).toHaveBeenCalledWith("token-123", weekendSet.id, "cal-1"); + expect(useCalendarSetsStore.getState().calendarSets[0].calendarIds).toContain("cal-1"); + }); + + it("removes membership immediately when a marked Set is selected", async () => { + useCalendarsStore.setState({ calendars: [owned] }); + useCalendarSetsStore.setState({ calendarSets: [workSet] }); + vi.mocked(calendarSetsApi.removeCalendar).mockResolvedValue(undefined); + renderCalendarList(); + + await openAddToSet("Personal"); + await userEvent.click(await screen.findByRole("menuitemcheckbox", { name: "Remove Personal from Work" })); + + expect(calendarSetsApi.removeCalendar).toHaveBeenCalledWith("token-123", workSet.id, "cal-1"); + expect(useCalendarSetsStore.getState().calendarSets[0].calendarIds).not.toContain("cal-1"); + }); + + it("works identically on a shared-in row", async () => { + useCalendarsStore.setState({ calendars: [editorAccess] }); + useCalendarSetsStore.setState({ calendarSets: [weekendSet] }); + renderCalendarList(); + + await openMenu("Team"); + + expect(await screen.findByRole("menuitem", { name: "Add to set" })).toBeInTheDocument(); + }); + + it("works identically on a Linked Calendar row", async () => { + useCalendarsStore.setState({ calendars: [linked] }); + useCalendarSetsStore.setState({ calendarSets: [weekendSet] }); + renderCalendarList(); + + await openMenu("Google Work"); + + expect(await screen.findByRole("menuitem", { name: "Add to set" })).toBeInTheDocument(); + }); + + it("works identically on a Subscribed Calendar row", async () => { + const subscribed: Calendar = { + id: "cal-subscribed", + name: "Holidays", + color: "#2ECC71FF", + access: "owner", + isOwner: true, + sourceUrl: "https://example.com/feed.ics", + }; + useCalendarsStore.setState({ calendars: [subscribed] }); + useCalendarSetsStore.setState({ calendarSets: [weekendSet] }); + renderCalendarList(); + + await openMenu("Holidays"); + + expect(await screen.findByRole("menuitem", { name: "Add to set" })).toBeInTheDocument(); + }); +}); diff --git a/src/components/layout/CalendarList.tsx b/src/components/layout/CalendarList.tsx index 8945a07..f3b273d 100644 --- a/src/components/layout/CalendarList.tsx +++ b/src/components/layout/CalendarList.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { formatDistanceToNow } from "date-fns"; import { Menu } from "@base-ui/react/menu"; -import { Check, Layers, MoreVertical, Plus, TriangleAlert, Users } from "lucide-react"; +import { Check, ChevronRight, Layers, MoreVertical, Plus, TriangleAlert, Users } from "lucide-react"; import { useNavigate } from "react-router"; import { CalendarPickerModal } from "../../settings/CalendarPickerModal"; import { Button } from "../ui/Button"; @@ -11,7 +11,8 @@ import { canManageCalendar, isLinkedCalendar, shareCountTooltip, type Calendar } import { connectionGroupLabel, groupCalendarsForSidebar, UNRESOLVED_CONNECTION } from "../../lib/calendarGrouping"; import { resolveCalendarFill } from "../../lib/calendarColors"; import { inScopeCalendars } from "../../lib/calendarSetScope"; -import { useActiveCalendarSet } from "../../lib/calendarSetsStore"; +import { useActiveCalendarSet, useCalendarSetsStore } from "../../lib/calendarSetsStore"; +import type { CalendarSet } from "../../lib/calendarSetsApi"; import { useAuthStore } from "../../lib/authStore"; import { useCalendarsStore } from "../../lib/calendarsStore"; import { useConnectionsStore } from "../../lib/connectionsStore"; @@ -64,6 +65,13 @@ export function CalendarList() { (state) => state.toggleCalendarChecked, ); const setActiveCalendarSetId = useShellStore((state) => state.setActiveCalendarSetId); + // The row menu's own "Add to set" submenu (#307, ADR-0082) reads the same + // Sets the top-bar switcher lists — it relies on CalendarSetSwitcher's + // effect to have fetched them already, the same way it relies on some + // ancestor to have fetched calendars, rather than fetching a second copy. + const calendarSets = useCalendarSetsStore((state) => state.calendarSets); + const addCalendarToSet = useCalendarSetsStore((state) => state.addCalendarToSet); + const removeCalendarFromSet = useCalendarSetsStore((state) => state.removeCalendarFromSet); const refreshCalendar = useCalendarsStore((state) => state.refreshCalendar); const setCalendarExposure = useCalendarsStore((state) => state.setCalendarExposure); // Linked Calendars group by Connection, so the sidebar needs the @@ -108,6 +116,9 @@ export function CalendarList() { summary: ExportSummary; } | null>(null); const [isConfirmingExport, setIsConfirmingExport] = useState(false); + // Keyed "calendarId:setId", so one Calendar's row can have several Set + // toggles in flight at once without disabling each other. + const [pendingSetToggles, setPendingSetToggles] = useState>(new Set()); // Narrowed to the Active Calendar Set before grouping (#303, ADR-0082): // an out-of-Set Calendar is absent from every heading below, not merely @@ -214,6 +225,30 @@ export function CalendarList() { } } + // Immediate effect, no save step, mirroring CalendarSetMembershipDialog + // (#302, #307): membership carries no Role and grants no Access, so this + // has nothing to do with canManageCalendar and runs identically for an + // owned, Subscribed, or shared-in row. + async function handleToggleSetMembership(calendar: Calendar, calendarSet: CalendarSet, isMember: boolean) { + const key = `${calendar.id}:${calendarSet.id}`; + setPendingSetToggles((keys) => new Set(keys).add(key)); + try { + if (isMember) { + await removeCalendarFromSet(calendarSet.id, calendar.id); + } else { + await addCalendarToSet(calendarSet.id, calendar.id); + } + } catch { + toast.error(`Failed to update "${calendarSet.name}".`); + } finally { + setPendingSetToggles((keys) => { + const next = new Set(keys); + next.delete(key); + return next; + }); + } + } + function renderCalendarItem(calendar: Calendar) { const isSubscribed = Boolean(calendar.sourceUrl); const isLinked = isLinkedCalendar(calendar); @@ -374,6 +409,56 @@ export function CalendarList() { Show on my devices )} + {/* Add to set (#307, ADR-0082): every row shape gets this, + since a Calendar Set is the viewer's own private view + filter, not a permission — unlike Share/Refresh/Export + above, it isn't gated on canManage or on origin. Absent + rather than an empty submenu at zero Sets (#307's own + acceptance criteria), matching this menu's habit of + dropping an item entirely rather than disabling it. */} + {calendarSets.length > 0 && ( + + + Add to set + + + + + + {calendarSets.map((calendarSet) => { + const isMember = calendarSet.calendarIds.includes(calendar.id); + const isPending = pendingSetToggles.has(`${calendar.id}:${calendarSet.id}`); + return ( + + handleToggleSetMembership(calendar, calendarSet, isMember) + } + aria-label={ + isMember + ? `Remove ${calendar.name} from ${calendarSet.name}` + : `Add ${calendar.name} to ${calendarSet.name}` + } + className={menuItemClasses} + > + + + + {calendarSet.name} + + ); + })} + + + + + )}
    {canManage ? ( Date: Fri, 11 Sep 2026 12:38:26 +0200 Subject: [PATCH 12/33] Creation dialogs offer to add the new Calendar to the Active Calendar Set The Calendar create dialog, the Subscribe dialog and the Connection Calendar picker each gain an unticked "Add to " checkbox, present only while a Calendar Set is active. The picker's checkbox covers the whole imported batch rather than one per row. Membership stays hand- curated (ADR-0082): ticking the box is the only way a newly created Calendar joins the Active Calendar Set. Fixes #308 --- src/components/layout/CalendarModal.test.tsx | 95 ++++++++++++++ src/components/layout/CalendarModal.tsx | 23 ++++ .../layout/SubscribeCalendarModal.test.tsx | 119 ++++++++++++++++++ .../layout/SubscribeCalendarModal.tsx | 23 ++++ src/settings/CalendarPickerModal.test.tsx | 73 +++++++++++ src/settings/CalendarPickerModal.tsx | 30 ++++- 6 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 src/components/layout/CalendarModal.test.tsx create mode 100644 src/components/layout/SubscribeCalendarModal.test.tsx diff --git a/src/components/layout/CalendarModal.test.tsx b/src/components/layout/CalendarModal.test.tsx new file mode 100644 index 0000000..737574a --- /dev/null +++ b/src/components/layout/CalendarModal.test.tsx @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// Same convention as the other component tests: the *Api modules are +// mocked, the stores are real. +vi.mock("../../lib/calendarsApi", () => ({ + calendarsApi: { + create: vi.fn(async () => undefined), + getDefaultReminders: vi.fn(async () => ({ timed: [], allDay: [] })), + }, +})); +vi.mock("../../lib/calendarSetsApi", async () => { + const actual = await vi.importActual( + "../../lib/calendarSetsApi", + ); + return { + ...actual, + calendarSetsApi: { ...actual.calendarSetsApi, addCalendar: vi.fn(async () => undefined) }, + }; +}); +vi.mock("../../lib/toast", () => ({ toast: { error: vi.fn(), success: vi.fn() } })); + +const { useAuthStore } = await import("../../lib/authStore"); +const { useCalendarsStore } = await import("../../lib/calendarsStore"); +const { useShellStore } = await import("../../lib/shellStore"); +const { useCalendarSetsStore } = await import("../../lib/calendarSetsStore"); +const { calendarSetsApi } = await import("../../lib/calendarSetsApi"); +const { CalendarModal } = await import("./CalendarModal"); + +const workSet = { id: 1, name: "Work", calendarIds: [] }; + +beforeEach(() => { + vi.clearAllMocks(); + useAuthStore.setState({ accessToken: "token-123" }); + useCalendarsStore.setState({ calendars: [] }); + useShellStore.setState({ checkedCalendarIds: new Set(), activeCalendarSetId: null }); + useCalendarSetsStore.setState({ calendarSets: [] }); +}); + +describe("CalendarModal add-to-set checkbox (#308)", () => { + it("is absent when on All calendars", () => { + render(); + expect(screen.queryByRole("checkbox", { name: /Add to/ })).not.toBeInTheDocument(); + }); + + it("is absent when editing an existing calendar, even with a Set active", () => { + useCalendarSetsStore.setState({ calendarSets: [workSet] }); + useShellStore.setState({ activeCalendarSetId: workSet.id }); + render( + , + ); + expect(screen.queryByRole("checkbox", { name: /Add to/ })).not.toBeInTheDocument(); + }); + + it("names the Active Calendar Set and starts unticked", () => { + useCalendarSetsStore.setState({ calendarSets: [workSet] }); + useShellStore.setState({ activeCalendarSetId: workSet.id }); + render(); + + const checkbox = screen.getByRole("checkbox", { name: /Add to Work/ }); + expect(checkbox).not.toBeChecked(); + }); + + it("adds the new calendar to the Active Calendar Set when ticked", async () => { + useCalendarSetsStore.setState({ calendarSets: [workSet] }); + useShellStore.setState({ activeCalendarSetId: workSet.id }); + render(); + + await userEvent.type(screen.getByLabelText("Name"), "New calendar"); + await userEvent.click(screen.getByRole("checkbox", { name: /Add to Work/ })); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(calendarSetsApi.addCalendar).toHaveBeenCalledWith( + "token-123", + workSet.id, + expect.any(String), + ); + }); + + it("does not add the new calendar to the Set when left unticked", async () => { + useCalendarSetsStore.setState({ calendarSets: [workSet] }); + useShellStore.setState({ activeCalendarSetId: workSet.id }); + render(); + + await userEvent.type(screen.getByLabelText("Name"), "New calendar"); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(calendarSetsApi.addCalendar).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/layout/CalendarModal.tsx b/src/components/layout/CalendarModal.tsx index b213d69..d72eea1 100644 --- a/src/components/layout/CalendarModal.tsx +++ b/src/components/layout/CalendarModal.tsx @@ -4,6 +4,7 @@ import { canManageCalendar, isSubscribedCalendar, type Calendar } from "../../li import { getNextUnusedColor } from "../../lib/calendarColors"; import { calendarsApi } from "../../lib/calendarsApi"; import { useAuthStore } from "../../lib/authStore"; +import { useActiveCalendarSet, useCalendarSetsStore } from "../../lib/calendarSetsStore"; import { useCalendarsStore } from "../../lib/calendarsStore"; import { createCalendarCascade } from "../../lib/createCalendarCascade"; import type { Reminder } from "../../lib/event"; @@ -44,6 +45,12 @@ export function CalendarModal(props: CalendarModalProps) { const emailAvailable = useAuthStore( (state) => state.user?.emailReminderChannelAvailable ?? false, ); + // Add-to-Set checkbox (#308, ADR-0082): present only on create and only + // while a Set is active, unticked by default so a Set's membership only + // ever changes because the User asked. + const activeCalendarSet = useActiveCalendarSet(); + const addCalendarToSet = useCalendarSetsStore((state) => state.addCalendarToSet); + const [addToActiveSet, setAddToActiveSet] = useState(false); const [name, setName] = useState(mode === "edit" ? props.calendar.name : ""); const [color, setColor] = useState(() => @@ -154,6 +161,11 @@ export function CalendarModal(props: CalendarModalProps) { // isOwner, so without it the New-event modal's Calendar picker treats // the brand-new Calendar as unwritable and hides it until reload. createCalendarCascade({ id, name: name.trim(), color, isOwner: true, access: "owner" }); + if (activeCalendarSet && addToActiveSet) { + addCalendarToSet(activeCalendarSet.id, id).catch(() => + toast.error("Failed to add the calendar to the set."), + ); + } props.onCreated?.(id); } onClose(); @@ -230,6 +242,17 @@ export function CalendarModal(props: CalendarModalProps) {
    + {mode === "create" && activeCalendarSet && ( + + )} + {calendarId && ( <> {/* Default reminders (ADR-0064): fire on every Event on this diff --git a/src/components/layout/SubscribeCalendarModal.test.tsx b/src/components/layout/SubscribeCalendarModal.test.tsx new file mode 100644 index 0000000..8a2da50 --- /dev/null +++ b/src/components/layout/SubscribeCalendarModal.test.tsx @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { SubscriptionPreview } from "../../lib/calendarsApi"; + +// Same convention as the other component tests: the *Api modules are +// mocked, the stores are real. +vi.mock("../../lib/calendarsApi", () => ({ + calendarsApi: { + previewSubscription: vi.fn(), + subscribe: vi.fn(), + }, +})); +vi.mock("../../lib/calendarSetsApi", async () => { + const actual = await vi.importActual( + "../../lib/calendarSetsApi", + ); + return { + ...actual, + calendarSetsApi: { ...actual.calendarSetsApi, addCalendar: vi.fn(async () => undefined) }, + }; +}); +vi.mock("../../lib/toast", () => ({ toast: { error: vi.fn(), success: vi.fn() } })); + +const { useAuthStore } = await import("../../lib/authStore"); +const { useCalendarsStore } = await import("../../lib/calendarsStore"); +const { useShellStore } = await import("../../lib/shellStore"); +const { useCalendarSetsStore } = await import("../../lib/calendarSetsStore"); +const { calendarsApi } = await import("../../lib/calendarsApi"); +const { calendarSetsApi } = await import("../../lib/calendarSetsApi"); +const { SubscribeCalendarModal } = await import("./SubscribeCalendarModal"); + +const workSet = { id: 1, name: "Work", calendarIds: [] }; + +const preview: SubscriptionPreview = { + name: "Holidays", + color: "#8E44ADFF", + eventCount: 3, + rangeStart: null, + rangeEnd: null, +}; + +beforeEach(() => { + vi.clearAllMocks(); + useAuthStore.setState({ accessToken: "token-123" }); + useCalendarsStore.setState({ calendars: [] }); + useShellStore.setState({ checkedCalendarIds: new Set(), activeCalendarSetId: null }); + useCalendarSetsStore.setState({ calendarSets: [] }); +}); + +async function previewFeed() { + await userEvent.type( + screen.getByLabelText("Calendar URL"), + "https://example.com/calendar.ics", + ); + await userEvent.click(screen.getByRole("button", { name: "Preview" })); +} + +describe("SubscribeCalendarModal add-to-set checkbox (#308)", () => { + it("is absent when on All calendars", async () => { + vi.mocked(calendarsApi.previewSubscription).mockResolvedValue(preview); + render(); + await previewFeed(); + await screen.findByLabelText("Name"); + + expect(screen.queryByRole("checkbox", { name: /Add to/ })).not.toBeInTheDocument(); + }); + + it("names the Active Calendar Set and starts unticked", async () => { + useCalendarSetsStore.setState({ calendarSets: [workSet] }); + useShellStore.setState({ activeCalendarSetId: workSet.id }); + vi.mocked(calendarsApi.previewSubscription).mockResolvedValue(preview); + render(); + await previewFeed(); + + const checkbox = await screen.findByRole("checkbox", { name: /Add to Work/ }); + expect(checkbox).not.toBeChecked(); + }); + + it("adds the subscribed calendar to the Active Calendar Set when ticked", async () => { + useCalendarSetsStore.setState({ calendarSets: [workSet] }); + useShellStore.setState({ activeCalendarSetId: workSet.id }); + vi.mocked(calendarsApi.previewSubscription).mockResolvedValue(preview); + vi.mocked(calendarsApi.subscribe).mockResolvedValue({ + id: "cal-1", + name: "Holidays", + color: "#8E44ADFF", + access: "owner", + isOwner: true, + }); + render(); + await previewFeed(); + + await userEvent.click(await screen.findByRole("checkbox", { name: /Add to Work/ })); + await userEvent.click(screen.getByRole("button", { name: "Subscribe" })); + + expect(calendarSetsApi.addCalendar).toHaveBeenCalledWith("token-123", workSet.id, "cal-1"); + }); + + it("does not add the subscribed calendar to the Set when left unticked", async () => { + useCalendarSetsStore.setState({ calendarSets: [workSet] }); + useShellStore.setState({ activeCalendarSetId: workSet.id }); + vi.mocked(calendarsApi.previewSubscription).mockResolvedValue(preview); + vi.mocked(calendarsApi.subscribe).mockResolvedValue({ + id: "cal-1", + name: "Holidays", + color: "#8E44ADFF", + access: "owner", + isOwner: true, + }); + render(); + await previewFeed(); + await screen.findByRole("checkbox", { name: /Add to Work/ }); + + await userEvent.click(screen.getByRole("button", { name: "Subscribe" })); + + expect(calendarSetsApi.addCalendar).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/layout/SubscribeCalendarModal.tsx b/src/components/layout/SubscribeCalendarModal.tsx index 03c3c58..82fa592 100644 --- a/src/components/layout/SubscribeCalendarModal.tsx +++ b/src/components/layout/SubscribeCalendarModal.tsx @@ -2,9 +2,11 @@ import { useState, type KeyboardEvent } from "react"; import { Dialog } from "@base-ui/react/dialog"; import { format } from "date-fns"; import { calendarsApi, type SubscriptionPreview } from "../../lib/calendarsApi"; +import { useActiveCalendarSet, useCalendarSetsStore } from "../../lib/calendarSetsStore"; import { useCalendarsStore } from "../../lib/calendarsStore"; import { useAuthStore } from "../../lib/authStore"; import { useShellStore } from "../../lib/shellStore"; +import { toast } from "../../lib/toast"; import { errorMessage } from "../../lib/errorMessage"; import { getNextUnusedColor } from "../../lib/calendarColors"; import { ColorSwatchPicker } from "./ColorSwatchPicker"; @@ -40,6 +42,11 @@ export function SubscribeCalendarModal({ onClose }: SubscribeCalendarModalProps) const addCheckedCalendarId = useShellStore( (state) => state.addCheckedCalendarId, ); + // Add-to-Set checkbox (#308, ADR-0082): same rule as the Calendar create + // dialog — present only while a Set is active, unticked by default. + const activeCalendarSet = useActiveCalendarSet(); + const addCalendarToSet = useCalendarSetsStore((state) => state.addCalendarToSet); + const [addToActiveSet, setAddToActiveSet] = useState(false); const [url, setUrl] = useState(""); const [preview, setPreview] = useState(null); @@ -88,6 +95,11 @@ export function SubscribeCalendarModal({ onClose }: SubscribeCalendarModalProps) keepAlarms, ); addCheckedCalendarId(calendar.id); + if (activeCalendarSet && addToActiveSet) { + addCalendarToSet(activeCalendarSet.id, calendar.id).catch(() => + toast.error("Failed to add the calendar to the set."), + ); + } onClose(); } catch (err) { setError(errorMessage(err)); @@ -193,6 +205,17 @@ export function SubscribeCalendarModal({ onClose }: SubscribeCalendarModalProps) this instance will send + + {activeCalendarSet && ( + + )} )} diff --git a/src/settings/CalendarPickerModal.test.tsx b/src/settings/CalendarPickerModal.test.tsx index eaf1d08..024de27 100644 --- a/src/settings/CalendarPickerModal.test.tsx +++ b/src/settings/CalendarPickerModal.test.tsx @@ -16,6 +16,15 @@ vi.mock("../lib/connectionsApi", async () => { }); vi.mock("../lib/calendarsApi", () => ({ calendarsApi: { list: vi.fn(), remove: vi.fn() } })); vi.mock("../lib/eventsApi", () => ({ eventsApi: { list: vi.fn() } })); +vi.mock("../lib/calendarSetsApi", async () => { + const actual = await vi.importActual( + "../lib/calendarSetsApi", + ); + return { + ...actual, + calendarSetsApi: { ...actual.calendarSetsApi, addCalendar: vi.fn(async () => undefined) }, + }; +}); const { connectionsApi } = await import("../lib/connectionsApi"); const { calendarsApi } = await import("../lib/calendarsApi"); @@ -25,8 +34,12 @@ const { useCalendarsStore } = await import("../lib/calendarsStore"); const { useEventsStore } = await import("../lib/eventsStore"); const { useShellStore } = await import("../lib/shellStore"); const { useWorkspacesStore } = await import("../lib/workspacesStore"); +const { useCalendarSetsStore } = await import("../lib/calendarSetsStore"); +const { calendarSetsApi } = await import("../lib/calendarSetsApi"); const { CalendarPickerModal } = await import("./CalendarPickerModal"); +const workSet = { id: 1, name: "Work", calendarIds: [] }; + const primary = { id: "primary", name: "someone@gmail.com", @@ -54,6 +67,8 @@ beforeEach(() => { useCalendarsStore.setState({ calendars: [] }); useEventsStore.setState({ events: [] }); useWorkspacesStore.setState({ activeWorkspaceId: 7 }); + useShellStore.setState({ activeCalendarSetId: null }); + useCalendarSetsStore.setState({ calendarSets: [] }); vi.mocked(calendarsApi.list).mockResolvedValue([]); vi.mocked(eventsApi.list).mockResolvedValue([]); }); @@ -266,4 +281,62 @@ describe("CalendarPickerModal", () => { expect(calendarsApi.remove).not.toHaveBeenCalled(); expect(screen.getByLabelText("someone@gmail.com")).toBeChecked(); }); + + // #308, ADR-0082: one checkbox covers the whole batch, not one per row. + describe("add-to-set checkbox", () => { + it("is absent when on All calendars", async () => { + vi.mocked(connectionsApi.listPickerCalendars).mockResolvedValue([primary]); + render(); + + await screen.findByLabelText("someone@gmail.com"); + expect(screen.queryByRole("checkbox", { name: /Add to/ })).not.toBeInTheDocument(); + }); + + it("names the Active Calendar Set and starts unticked", async () => { + useShellStore.setState({ activeCalendarSetId: workSet.id }); + useCalendarSetsStore.setState({ calendarSets: [workSet] }); + vi.mocked(connectionsApi.listPickerCalendars).mockResolvedValue([primary, sharedIn]); + render(); + + await screen.findByLabelText("someone@gmail.com"); + const checkbox = screen.getByRole("checkbox", { name: /Add to Work/ }); + expect(checkbox).not.toBeChecked(); + }); + + it("adds every calendar in the batch to the Active Calendar Set when ticked", async () => { + useShellStore.setState({ activeCalendarSetId: workSet.id }); + useCalendarSetsStore.setState({ calendarSets: [workSet] }); + vi.mocked(connectionsApi.listPickerCalendars).mockResolvedValue([primary, sharedIn]); + vi.mocked(connectionsApi.importCalendars).mockResolvedValue([ + { id: "cal-1", name: "someone@gmail.com", color: "#0B8043FF" }, + { id: "cal-2", name: "Team calendar", color: "#7627BBFF" }, + ]); + render(); + + await userEvent.click(await screen.findByLabelText("Team calendar")); + await userEvent.click(screen.getByRole("checkbox", { name: /Add to Work/ })); + await userEvent.click(screen.getByRole("button", { name: /Add 2 calendars/ })); + + await waitFor(() => + expect(calendarSetsApi.addCalendar).toHaveBeenCalledWith("token-123", workSet.id, "cal-1"), + ); + expect(calendarSetsApi.addCalendar).toHaveBeenCalledWith("token-123", workSet.id, "cal-2"); + }); + + it("does not add the batch to the Set when left unticked", async () => { + useShellStore.setState({ activeCalendarSetId: workSet.id }); + useCalendarSetsStore.setState({ calendarSets: [workSet] }); + vi.mocked(connectionsApi.listPickerCalendars).mockResolvedValue([primary]); + vi.mocked(connectionsApi.importCalendars).mockResolvedValue([ + { id: "cal-1", name: "someone@gmail.com", color: "#0B8043FF" }, + ]); + render(); + + await screen.findByLabelText("someone@gmail.com"); + await userEvent.click(screen.getByRole("button", { name: /Add 1 calendar/ })); + + await waitFor(() => expect(connectionsApi.importCalendars).toHaveBeenCalled()); + expect(calendarSetsApi.addCalendar).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/settings/CalendarPickerModal.tsx b/src/settings/CalendarPickerModal.tsx index 414da98..4eb371a 100644 --- a/src/settings/CalendarPickerModal.tsx +++ b/src/settings/CalendarPickerModal.tsx @@ -1,8 +1,10 @@ import { useEffect, useMemo, useState } from "react"; import { Dialog } from "@base-ui/react/dialog"; +import { useActiveCalendarSet, useCalendarSetsStore } from "../lib/calendarSetsStore"; import { useConnectionsStore } from "../lib/connectionsStore"; import { useEventsStore } from "../lib/eventsStore"; import { refetchCalendarsAndReconcile } from "../lib/shellStore"; +import { toast } from "../lib/toast"; import { type PickerCalendar } from "../lib/connectionsApi"; import { useWorkspacesStore } from "../lib/workspacesStore"; import { deleteCalendarCascade } from "../lib/deleteCalendarCascade"; @@ -40,6 +42,12 @@ export function CalendarPickerModal({ connectionId, onClose }: CalendarPickerMod // time, throwing "No active workspace" on the request workspaceHeaders() // builds before AppShell's fetch has had a chance to answer. const activeWorkspaceId = useWorkspacesStore((state) => state.activeWorkspaceId); + // Add-to-Set checkbox (#308, ADR-0082): one checkbox covers the whole + // batch rather than one per row, present only while a Set is active and + // unticked by default. + const activeCalendarSet = useActiveCalendarSet(); + const addCalendarToSet = useCalendarSetsStore((state) => state.addCalendarToSet); + const [addToActiveSet, setAddToActiveSet] = useState(false); const [calendars, setCalendars] = useState(null); const [checkedIds, setCheckedIds] = useState>(new Set()); @@ -155,7 +163,16 @@ export function CalendarPickerModal({ connectionId, onClose }: CalendarPickerMod setIsImporting(true); setError(null); try { - await importCalendars(connectionId, toImport); + const imported = await importCalendars(connectionId, toImport); + if (activeCalendarSet && addToActiveSet) { + await Promise.all( + imported.map((calendar) => + addCalendarToSet(activeCalendarSet.id, calendar.id).catch(() => + toast.error(`Failed to add "${calendar.name}" to the set.`), + ), + ), + ); + } // The picker's whole point is calendars appearing on the grid // immediately (#286) — a full re-fetch, not a local append, since the // server resolves each new Calendar's Access/isOwner/ownerName/ @@ -236,6 +253,17 @@ export function CalendarPickerModal({ connectionId, onClose }: CalendarPickerMod )} + {activeCalendarSet && ( + + )} +
    Date: Fri, 11 Sep 2026 12:45:31 +0200 Subject: [PATCH 13/33] Extract the add-to-Set checkbox shared by the three creation dialogs CalendarModal, SubscribeCalendarModal and CalendarPickerModal each carried an identical label+Checkbox block for #308's add-to-Set checkbox. Pulled into AddToActiveSetCheckbox, which renders nothing when there's no Active Calendar Set so callers need no guard around it. --- .../layout/AddToActiveSetCheckbox.tsx | 32 +++++++++++++++++++ src/components/layout/CalendarModal.tsx | 16 ++++------ .../layout/SubscribeCalendarModal.tsx | 16 ++++------ src/settings/CalendarPickerModal.tsx | 16 ++++------ 4 files changed, 51 insertions(+), 29 deletions(-) create mode 100644 src/components/layout/AddToActiveSetCheckbox.tsx diff --git a/src/components/layout/AddToActiveSetCheckbox.tsx b/src/components/layout/AddToActiveSetCheckbox.tsx new file mode 100644 index 0000000..4a6c6ea --- /dev/null +++ b/src/components/layout/AddToActiveSetCheckbox.tsx @@ -0,0 +1,32 @@ +import type { CalendarSet } from "../../lib/calendarSetsApi"; +import { Checkbox } from "../ui/Checkbox"; + +interface AddToActiveSetCheckboxProps { + activeCalendarSet: CalendarSet | null; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +} + +// The three creation dialogs' add-to-Set checkbox (#308, ADR-0082): +// unticked by default so a Set's membership only ever changes because the +// User asked, absent entirely on "All calendars" since there's nothing to +// opt into. Renders nothing itself when there's no Active Calendar Set, so +// callers don't need their own guard around it. +export function AddToActiveSetCheckbox({ + activeCalendarSet, + checked, + onCheckedChange, +}: AddToActiveSetCheckboxProps) { + if (!activeCalendarSet) return null; + + return ( + + ); +} diff --git a/src/components/layout/CalendarModal.tsx b/src/components/layout/CalendarModal.tsx index d72eea1..5b94d57 100644 --- a/src/components/layout/CalendarModal.tsx +++ b/src/components/layout/CalendarModal.tsx @@ -16,6 +16,7 @@ import { toReminderDrafts, type ReminderDraft, } from "../../calendar-grid/reminderDrafts"; +import { AddToActiveSetCheckbox } from "./AddToActiveSetCheckbox"; import { ColorSwatchPicker } from "./ColorSwatchPicker"; import { Button } from "../ui/Button"; import { buttonClasses } from "../ui/buttonClasses"; @@ -242,15 +243,12 @@ export function CalendarModal(props: CalendarModalProps) {
    - {mode === "create" && activeCalendarSet && ( - + {mode === "create" && ( + )} {calendarId && ( diff --git a/src/components/layout/SubscribeCalendarModal.tsx b/src/components/layout/SubscribeCalendarModal.tsx index 82fa592..79c5e97 100644 --- a/src/components/layout/SubscribeCalendarModal.tsx +++ b/src/components/layout/SubscribeCalendarModal.tsx @@ -9,6 +9,7 @@ import { useShellStore } from "../../lib/shellStore"; import { toast } from "../../lib/toast"; import { errorMessage } from "../../lib/errorMessage"; import { getNextUnusedColor } from "../../lib/calendarColors"; +import { AddToActiveSetCheckbox } from "./AddToActiveSetCheckbox"; import { ColorSwatchPicker } from "./ColorSwatchPicker"; import { Button } from "../ui/Button"; import { buttonClasses } from "../ui/buttonClasses"; @@ -206,16 +207,11 @@ export function SubscribeCalendarModal({ onClose }: SubscribeCalendarModalProps) - {activeCalendarSet && ( - - )} + )} diff --git a/src/settings/CalendarPickerModal.tsx b/src/settings/CalendarPickerModal.tsx index 4eb371a..f84b5f8 100644 --- a/src/settings/CalendarPickerModal.tsx +++ b/src/settings/CalendarPickerModal.tsx @@ -9,6 +9,7 @@ import { type PickerCalendar } from "../lib/connectionsApi"; import { useWorkspacesStore } from "../lib/workspacesStore"; import { deleteCalendarCascade } from "../lib/deleteCalendarCascade"; import { errorMessage } from "../lib/errorMessage"; +import { AddToActiveSetCheckbox } from "../components/layout/AddToActiveSetCheckbox"; import { Button } from "../components/ui/Button"; import { buttonClasses } from "../components/ui/buttonClasses"; import { Checkbox } from "../components/ui/Checkbox"; @@ -253,16 +254,11 @@ export function CalendarPickerModal({ connectionId, onClose }: CalendarPickerMod )} - {activeCalendarSet && ( - - )} +
    Date: Fri, 11 Sep 2026 14:49:36 +0200 Subject: [PATCH 14/33] Tasks are their own VTODO-shaped entity, on two independent date axes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTEXT.md gains a Tasks section — Task, Task List, Deadline, Time block, Task bucket, Completion, Priority, Tasks panel — plus explicit non-terms for a repeating Task and for a Reminder on a Task, so a later reader can tell a decision from an omission. ADR-0083 records the entity model and, at more length, the two cheaper designs it refuses: a Task as an Event with a flag (which puts an "unless it's a task" branch into recurrence expansion, the Anchor zone, multi-day rendering and the Save plan), and a Task List as a Calendar carrying CalDAV's supported-calendar-component-set (whose blast radius was measured at 16 query sites before being rejected on other grounds — it doubles the meaning of Access, Source and import). The second turns entirely on Tasks being private, so the ADR names the question to re-ask before reopening it. Deadline (DUE) and Time block (DTSTART + DURATION) are independent: RFC 5545 makes DUE and DURATION mutually exclusive and DUE means a deadline, so storing a block's end in DUE would destroy the deadline the moment work is scheduled against it. "Due Friday, blocked Tuesday" is the ordinary case, not a conflict to reconcile. Spec and tickets in #309. --- CONTEXT.md | 38 ++++++++++ ...tasks-are-their-own-vtodo-shaped-entity.md | 70 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 docs/adr/0083-tasks-are-their-own-vtodo-shaped-entity.md diff --git a/CONTEXT.md b/CONTEXT.md index ac6117a..fb6eafd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -357,6 +357,44 @@ _Avoid_: calendar group (Group is a set of Members), collection, folder, profile The Calendar Set a Session is currently looking through, or none — the default, shown as "All calendars". Narrows *what exists* in the UI rather than what is switched on: a Calendar outside it is absent from the sidebar and from the Event modal's Calendar picker, not unchecked. Leaves Calendar toggle alone, which stays one global answer per Calendar, so the grid renders what is both in the Active Calendar Set and toggled on. Session state in the same sense Active view is, but with no Preference seeding it: every load and every Workspace switch returns to "All calendars", deliberately and not as a memory of where the User last was. Invisible below the UI — Exposure still decides CalDAV home-set membership, and Reminders, Notifications and Write-back are all indifferent to what their User happens to be looking through today. See ADR-0082. _Avoid_: current set, selected set, set filter, scope +## Tasks + +**Task**: +A unit of work a User tracks, belonging to exactly one Task List and private to its owner. Modelled on iCalendar's `VTODO` — the entity an Event is deliberately not: it may carry no time at all, where an Event is defined by having one. Carries a Deadline and a Time block on two independent axes, each separately absent, and their presence alone decides where it renders, with no discriminator beside them: a Time block draws it on the hourly grid, a Deadline alone puts it in the all-day lane, and neither leaves it in the Tasks panel only. Never an Event and never backed by one — time-blocking a Task sets its own fields rather than creating an Event, because an Event lives on a Calendar and would publish a private Task to everyone with Access. See ADR-0083. +_Avoid_: todo, item, action item, ticket (that is a Provider's word for its own issues, coming later) + +**Deadline**: +When a Task is due, held as its `VTODO` `DUE`. Absent by default. The *only* thing that decides which Task bucket a Task falls in when it has one, and deliberately independent of the Time block: "due Friday, worked on Tuesday" is the ordinary case for time-blocking, not a conflict to reconcile, so a Deadline is never moved or overwritten by scheduling work against it. See ADR-0083. +_Avoid_: due date (it may carry a time), due, target date + +**Time block**: +The window a User has scheduled to work on a Task, held as its `VTODO` `DTSTART` plus a duration — never as `DUE`, which RFC 5545 makes mutually exclusive with `DURATION` and which means a deadline rather than an end. What a Task dragged onto the grid acquires, and what it renders as there, drawn in its Task List's color. Absent by default, and absent again if the block is dragged back off. Not an Event and not backed by one, but shares the grid with Occurrences and so competes with them for horizontal space in overlap layout (ADR-0004). See ADR-0083. +_Avoid_: scheduled time, work block, task event, block (too general) + +**Task bucket**: +One of the four headings the Tasks panel derives for a Task — **Overdue**, **Today**, **Upcoming**, or **No date** — computed at render from its Deadline against today in the Viewer zone, falling back to its Time block's start when it has no Deadline, and **No date** when it has neither. Computed, never stored, in exactly the sense an Occurrence is: nothing moves a Task from Upcoming to Today at midnight, because there is nothing stored to move — the panel re-derives and the Task is elsewhere. A Task with a Friday Deadline blocked on Tuesday is Upcoming, since a Deadline always wins over a Time block; the panel answers "when is this due", the grid answers "when am I doing it". See ADR-0083. +_Avoid_: group (Group is a set of Workspace Members — a hard collision), section, category, status (that is completion) + +**Task List**: +A named, private container of Tasks belonging to one User within one Workspace. A container, not a filter — a Task belongs to exactly one, which is what separates it from a Calendar Set (a saved filter over Calendars, where membership is many-to-many and optional). Deliberately not a Calendar: it holds no Events, and Access, Share, Role, Source and Exposure are all concepts it does not have, its read path being `WHERE user_id = ?` on the same terms as a Calendar Set. Carries its own color, which is what a Task's Time block renders in — a Task on the grid takes no Calendar color, having no Calendar. Exactly one of a User's Task Lists per Workspace is the **default**, an ordinary row carrying a movable flag rather than a hardcoded absence: it is where a Task with no stated Task List goes, where a deleted Task List's Tasks are reparented, and the one Task List that cannot itself be deleted while it holds the flag. Auto-created as "Inbox" when a `(User, Workspace)` pair comes into being, and renameable and recolorable afterward like any other. See ADR-0082, ADR-0083. +_Avoid_: list (too general — it collides with the ordinary programming word in both prose and code), task group, project, folder, calendar (it is not one) + +**Completion**: +Whether a Task is done, held as the instant it was completed and absent while it is not — never a status enum. `VTODO`'s other `STATUS` values (`IN-PROCESS`, `CANCELLED`) and `PERCENT-COMPLETE` are deliberately unmodelled: nothing in this app can produce one, there being no CalDAV or Provider path inbound to Tasks, so modelling them would carry three values unreachable by construction. Deliberately unlike Response, which models all four `PARTSTAT` values precisely because a mail client can send any of them. Toggled by one click with no confirmation (ADR-0068) and written optimistically (ADR-0067), from the Tasks panel or from the Task's Time block on the grid — which is why completing one leaves the block in place, rendered completed, rather than removing it from under the cursor. Never affects which Task bucket a Task falls in: one completed late stays under Overdue, struck through, because it was. See ADR-0026, ADR-0046, ADR-0083. +_Avoid_: done, status (that is `VTODO`'s enum, which this is not), checked, complete (as a noun) + +There is deliberately **no term for a repeating Task.** A `VTODO` may carry an `RRULE`, but a recurring todo's completion has no good resting place: completing one instance must produce the next, which means either storing instances — contradicting the computed-never-stored rule Task bucket and Occurrence both hold to — or mutating the row's Deadline on every completion. Recurrence on a Task is unmodelled rather than partly modelled. + +**Priority**: +How important a Task is — **None** (the default), **Low**, **Medium** or **High** — held as `VTODO`'s `PRIORITY` and mapped to its 0–9 range rather than to an enum of this app's own, so the numeric value a native client writes survives. One of the three axes the Tasks panel can group by, and the only one of them that is a stored field rather than something derived. See ADR-0083. +_Avoid_: importance, urgency (that is what a Deadline expresses), severity, rank + +**Tasks panel**: +The right-hand panel holding a User's Tasks, toggled from the top bar and closed by default. Shows Tasks from the Task Lists currently checked in its Lists filter, arranged under one grouping axis at a time — by Task bucket, by Task List, or by Priority — and hiding completed ones unless asked. All of its view state is the Session's, in `shellStore` beside Active view and the Active Calendar Set, with no Preference behind any of it and nothing persisted between loads. Its Lists filter is the Task List counterpart to Calendar toggle and carries the same reconcile rule: a Task List unseen at the last reconcile is checked automatically, one deliberately unchecked stays unchecked. See ADR-0082, ADR-0083. +_Avoid_: task sidebar (Sidebar is the left one), todo panel, right sidebar, drawer + +There is deliberately **no Reminder on a Task.** A Reminder belongs to one User on one Event, is resolved server-side against per-Calendar Default reminders, and is served per-principal over CalDAV (ADR-0020, ADR-0064) — extending all of that to a second entity is a feature rather than a field, and a second parallel reminder path would have to be merged back into the first. A Deadline therefore notifies nobody in this cut. Subtasks (`RELATED-TO`), tags (`CATEGORIES`), Attachments and Location are unmodelled on the same terms: cheap to add later, not free to add badly now. + ## Deployment **Data directory**: diff --git a/docs/adr/0083-tasks-are-their-own-vtodo-shaped-entity.md b/docs/adr/0083-tasks-are-their-own-vtodo-shaped-entity.md new file mode 100644 index 0000000..548d498 --- /dev/null +++ b/docs/adr/0083-tasks-are-their-own-vtodo-shaped-entity.md @@ -0,0 +1,70 @@ +# Tasks are their own VTODO-shaped entity, on two independent date axes + +Status: accepted — the first entity in this repo that is not an Event or a Calendar; adopts ADR-0082's private-outright model and deliberately does not extend ADR-0034 (Access), ADR-0052 (Source) or ADR-0025 (CalDAV) to it + +A Task is a first-class entity modelled on iCalendar's `VTODO`, belonging to a Task List that is deliberately **not** a Calendar, carrying a Deadline and a Time block on two independent axes whose presence alone decides where it renders. Both Tasks and Task Lists are private to one User outright, on ADR-0082's terms. Nothing ships over CalDAV or to a Provider in this cut. + +The two cheap designs this rejects are the two a reader will assume were overlooked, so both are recorded below. + +## Why a Task is not an Event + +The cheapest design is `events` plus `is_task` and `completed_at`: it inherits recurrence expansion (ADR-0016), the all-day lane (ADR-0017, ADR-0069), overlap layout (ADR-0004), drag-to-move, Reminders, Access and Write-back, all for nothing. + +It was refused because the two entities disagree about their defining property. CONTEXT.md defines an Event as "a titled time block… Its start/end define the first occurrence and the duration every Occurrence inherits." A Task's defining property is that **it need not have a time at all** — the `No date` bucket is a Task with neither Deadline nor Time block. Putting that on `events` makes `start`/`end` nullable, and every consumer of an Event's time — recurrence expansion, the Anchor zone (ADR-0019), multi-day all-day rendering (ADR-0069), the Save plan (ADR-0066) — acquires an "unless it's a task" branch. That is cheap in week one and unremovable in month six. + +Shaping the table after `VTODO` instead costs nothing now and makes eventual CalDAV exposure a codec-and-backend job rather than a migration. It is a shape, not a promise: the codec is VEVENT-only today and stays that way here. + +## Why a Task List is not a Calendar + +The other cheap design is a component kind on `calendars` (`events` | `tasks`) — which is not an invention but CalDAV's own `supported-calendar-component-set`, and is exactly how Apple Reminders lists are modelled. It would give Task Lists Access, Share, Workspace scoping, colour and Exposure for free. + +The blast radius was measured before rejecting it and is genuinely modest: 16 query sites across 3 files touch `calendars` directly. **The mechanical cost is not the objection.** + +The objection is that every concept currently defined over Calendar silently acquires a second meaning. `Access` would resolve for a thing with no Events. A `Source` — Subscription or Connection — becomes representable on a list of Tasks, and ADR-0052's "a Calendar has zero or one Source" would have to say what subscribing to a task list means. ICS import could target one. `calendarPickerEmptyReason` would have to exclude them. CONTEXT.md's own opening definition — "a named, independently-toggleable collection that groups events" — would become "groups Events *or* Tasks, never both", and a definition with an internal disjunction is usually two concepts sharing a word. + +This decision **turns entirely on Tasks being private**, and would be wrong if they were not. Retrofitting sharing onto a parallel entity is the expensive path; doing it via a component kind is the cheap one. The component kind is therefore the right answer to a different question, and the question to re-ask before reopening this is not "was a parallel table a mistake" but "**do Task Lists need to be shared now?**" If that answer ever becomes yes, this ADR is the one to supersede. + +## Why time-blocking does not create an Event + +Motion and Reclaim both create a real calendar event for a scheduled task, so this is a live alternative rather than a strawman. It dies on privacy: an Event belongs to a Calendar, a Calendar is shared with a Role, so time-blocking a private Task onto one would publish it to everyone with Access. Avoiding that needs a hidden per-User Calendar — a new grouping noun that punches through ADR-0034's calendar-scoped ownership — or it needs Tasks to stop being private. + +It also creates a two-row sync problem with no good answers: delete the Event, does the Task un-block? Complete the Task, does the Event vanish? Drag the Event to another Calendar, what does that mean for a Task that has no Calendar? Every one of those is a rule to invent and then keep true forever. + +## The two date axes, and why they are independent + +`VTODO`'s `DUE` is a **deadline**, and RFC 5545 makes `DUE` and `DURATION` mutually exclusive. So the Deadline and the Time block are separate fields and neither is derived from the other: + +- **Deadline** (`DUE`) — when it is due. Decides the Task bucket. +- **Time block** (`DTSTART` + duration) — when it is being worked on. Decides where it draws on the grid. + +"Due Friday, blocked Tuesday" is the ordinary case for time-blocking, not a conflict to reconcile. Collapsing the two — storing a Time block's end in `DUE` — was the first model drafted and it silently destroys the deadline the moment you schedule work against it, which removes the feature's main use case. **Rescheduling never touches the Deadline.** + +## Decision + +- `tasks` and `task_lists`, both scoped `(user_id, workspace_id)` and cascading on both, as a new append-only migration per ADR-0048. +- `tasks` columns named for what `VTODO` holds: title (`SUMMARY`), notes (`DESCRIPTION`), `due` (`DUE`), `start` + duration (`DTSTART`), `priority` (`PRIORITY`, 0–9), `completed_at` (`COMPLETED`). +- **Rendering is a strict precedence**, so a Task appears exactly once: Time block → hourly grid; else Deadline → all-day lane; else the Tasks panel only. No discriminator column — presence of the fields *is* the state, as with a Floating Event's absent `tzid` and "All calendars" being the absence of a Set. +- **Task buckets are derived at render, never stored** — Overdue / Today / Upcoming / No date, from the Deadline against today in the Viewer zone, falling back to the Time block's start. Storing them would require a job moving every Task across buckets at midnight in every User's timezone. Four buckets, not three: an undated Task is not overdue, not today, and not upcoming. +- **Completion is `completed_at`, nullable, not a status enum.** `IN-PROCESS`, `CANCELLED` and `PERCENT-COMPLETE` are unmodelled because nothing here can produce one — no CalDAV in, no Provider in, one radio button. Deliberately unlike Response (ADR-0046), which models all four `PARTSTAT` values precisely because a mail client can send any of them; ADR-0026 already establishes lossy round-trips as an accepted position. +- **Exactly one Task List per `(User, Workspace)` is the default** — a row with a movable flag, auto-created as "Inbox", renameable and recolorable like any other. Not a nullable `list_id` meaning Inbox: Inbox has every property a Task List has (name, colour, a row in the Lists filter), and the absence model would make each of those a hardcoded constant plus a sentinel id, and Inbox the one Task List that cannot be renamed. +- **Deleting a Task List reparents its Tasks to the default**; the default cannot be deleted while it holds the flag. +- **Private outright, with no sharing mechanism and no Role**, exactly as ADR-0082 specifies for a Calendar Set. There is no owner-vs-grantee axis, so "can B see A's Task?" is not a question the code can ask. +- **No effect below the UI.** No CalDAV exposure, no Provider Write-back, no Refresh, no `VALARM`, no participation in ICS import or export — the codec stays VEVENT-only. A Task is a thing one User has; nothing that runs without a tab open consults one. + +## Considered Options + +- **A `VTODO`-shaped entity of its own, private, on two independent date axes (chosen).** +- **An Event with `is_task` + `completed_at`.** Free machinery, at the cost of nullable start/end and a task-branch in every time-consuming code path. +- **A Task List as a Calendar with a component kind.** CalDAV-native and only ~16 query sites, but doubles the meaning of Access, Source, import and the Calendar definition itself. The right answer if Task Lists ever need sharing. +- **Time-blocking creates a linked Event.** What Motion and Reclaim do; incompatible with Tasks being private, and a permanent two-row sync problem. +- **A single date field, with a flag for "is this a deadline or a block".** Simpler schema, but cannot express "due Friday, working Tuesday", which is the entire point of time-blocking. + +## Consequences + +- **The Event machinery is not inherited and must be paralleled.** Overlap layout (ADR-0004) has to accept a mixed population of Occurrences and Time blocks or they will draw over each other, and `useOccurrenceDragCommit` needs a Task sibling. This is the real, accepted price of not building on `events`. +- **A Deadline notifies nobody.** With no Reminder on a Task (a Reminder belongs to one User on one Event, resolved server-side per-principal — ADR-0020, ADR-0064), a deadline passes in silence. This is the design's weakest seam, accepted knowingly: the alternative is a second reminder path that would later have to be merged into the first. +- **A drop on the all-day lane clears the Time block.** It must, or precedence would keep drawing the Task on the hourly grid and the drag would visibly snap back after having written something. It is the one gesture in the feature that destroys data rather than adding it. +- **Month view can move a Time block but never create one.** A drop there preserves time-of-day if there is one and otherwise sets a Deadline, because inventing an hour from a gesture that named no hour is worse than the asymmetry with Week view. Year view renders no Tasks at all; its Mini-months stay day-numbers-and-density-dots, and "Show tasks on calendar" is inert there. +- **`tasks` is not fetched by date window.** A no-date Task has no window to be in and the panel must always show it, so the store is shaped after `calendarsStore` rather than `eventsStore` — which is only safe because Tasks are private (small cardinality) and have no recurrence to expand. Completed Tasks are the bounded exception. +- **Manual ordering is unavailable while grouping is derived.** A stored position would be a position in a set that dissolves at midnight and re-forms with different members. It becomes coherent only under Group-by-Task-List, where the rows are a stable list, and can be added there without disturbing the derived order. +- **"Group" is now overloaded in conversation and must not be in code.** A Workspace Group is a set of Members; the panel's headings are Task buckets. The UI's "Group by" label is copy, not a term. From 3461f6346048b8207391af4e912d87f877c6b539 Mon Sep 17 00:00:00 2001 From: XiovV Date: Fri, 11 Sep 2026 15:26:24 +0200 Subject: [PATCH 15/33] Task Lists exist, with Inbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A User can open the Tasks panel (top-bar button, closed by default, session state in shellStore) and manage their Task Lists in it. The Lists filter dropdown shows each Task List with a colour checkbox and a checked-count, and creates a new one inline via "New list" without leaving the panel — checked automatically, on the same reconcile rule Calendar toggle uses, now extracted into a shared reconcileCheckedIds helper both stores call. task_lists is scoped (user_id, workspace_id), cascading on both, with a colour and a movable is_default flag (ADR-0083). Inbox is provisioned inside the existing transaction seam (ADR-0018) at both points a (User, Workspace) pair comes into being: createForOwnerTx (Workspace creation, covering Bootstrap and Register) and AddMemberInTx (Workspace Invite acceptance, covering both the new-account and existing-account paths). /api/task-lists supports list/create/rename/recolour/set-default/ delete, private outright like Calendar Sets (ADR-0082) — every route resolves by (id, caller, active workspace) and a mismatch is ErrNotFound, not a distinguishable forbidden. Promoting a Task List to default clears the old one atomically in one transaction; deleting the current default is refused until another is promoted. Handler tests (mirroring handlers/calendar_set_test.go, the only backend seam) cover CRUD, cross-user 404s, Inbox provisioning on both call paths, default-flag moves, and cascade on User and Workspace deletion. Fixes #317 --- server/cmd/server/main.go | 2 +- server/internal/app/app.go | 2 + .../db/migrations/00010_task_lists.sql | 31 + server/internal/handlers/task_list.go | 178 +++++ server/internal/handlers/task_list_test.go | 621 ++++++++++++++++++ server/internal/repository/task_list.go | 172 +++++ server/internal/router/router.go | 21 +- server/internal/router/router_test.go | 2 +- server/internal/service/graph.go | 6 +- server/internal/service/task_list.go | 173 +++++ server/internal/service/workspace.go | 35 +- src/components/layout/AppShell.tsx | 7 + src/components/layout/TaskListToggle.tsx | 45 ++ .../layout/TaskListsFilter.test.tsx | 116 ++++ src/components/layout/TaskListsFilter.tsx | 124 ++++ src/components/layout/TasksPanel.tsx | 32 + src/components/layout/TopBar.tsx | 12 +- src/lib/createTaskListCascade.test.ts | 51 ++ src/lib/createTaskListCascade.ts | 17 + src/lib/reconcileCheckedIds.test.ts | 31 + src/lib/reconcileCheckedIds.ts | 22 + src/lib/shellStore.test.ts | 83 +++ src/lib/shellStore.ts | 80 ++- src/lib/taskListsApi.ts | 91 +++ src/lib/taskListsStore.test.ts | 50 ++ src/lib/taskListsStore.ts | 68 ++ 26 files changed, 2055 insertions(+), 17 deletions(-) create mode 100644 server/internal/db/migrations/00010_task_lists.sql create mode 100644 server/internal/handlers/task_list.go create mode 100644 server/internal/handlers/task_list_test.go create mode 100644 server/internal/repository/task_list.go create mode 100644 server/internal/service/task_list.go create mode 100644 src/components/layout/TaskListToggle.tsx create mode 100644 src/components/layout/TaskListsFilter.test.tsx create mode 100644 src/components/layout/TaskListsFilter.tsx create mode 100644 src/components/layout/TasksPanel.tsx create mode 100644 src/lib/createTaskListCascade.test.ts create mode 100644 src/lib/createTaskListCascade.ts create mode 100644 src/lib/reconcileCheckedIds.test.ts create mode 100644 src/lib/reconcileCheckedIds.ts create mode 100644 src/lib/taskListsApi.ts create mode 100644 src/lib/taskListsStore.test.ts create mode 100644 src/lib/taskListsStore.ts diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 7fb7fdf..9ff555c 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -87,7 +87,7 @@ func main() { } } - handler, err := router.New(logger, a.AuthHandler, a.CalendarHandler, a.EventHandler, a.AttachmentHandler, a.NotificationHandler, a.AppPasswordHandler, a.AccountHandler, a.UserHandler, a.WorkspaceHandler, a.GroupHandler, a.CalendarSetHandler, a.ConnectionHandler, a.CalDAVHandler, a.Auth, a.Auth, a.AppPasswords, a.RateLimiter, a.Auth, a.Workspaces) + handler, err := router.New(logger, a.AuthHandler, a.CalendarHandler, a.EventHandler, a.AttachmentHandler, a.NotificationHandler, a.AppPasswordHandler, a.AccountHandler, a.UserHandler, a.WorkspaceHandler, a.GroupHandler, a.CalendarSetHandler, a.TaskListHandler, a.ConnectionHandler, a.CalDAVHandler, a.Auth, a.Auth, a.AppPasswords, a.RateLimiter, a.Auth, a.Workspaces) if err != nil { logger.Error("failed to build router", "error", err) os.Exit(1) diff --git a/server/internal/app/app.go b/server/internal/app/app.go index 8dc0732..3fdf2ec 100644 --- a/server/internal/app/app.go +++ b/server/internal/app/app.go @@ -57,6 +57,7 @@ type App struct { WorkspaceHandler *handlers.WorkspaceHandler GroupHandler *handlers.GroupHandler CalendarSetHandler *handlers.CalendarSetHandler + TaskListHandler *handlers.TaskListHandler ConnectionHandler *handlers.ConnectionHandler CalDAVBackend *caldavserver.Backend @@ -120,6 +121,7 @@ func newFromGraph(graph *service.Graph, cfg config.Config) *App { a.WorkspaceHandler = handlers.NewWorkspaceHandler(a.Workspaces) a.GroupHandler = handlers.NewGroupHandler(a.Groups) a.CalendarSetHandler = handlers.NewCalendarSetHandler(a.CalendarSets) + a.TaskListHandler = handlers.NewTaskListHandler(a.TaskLists) a.ConnectionHandler = handlers.NewConnectionHandler(a.Connections) a.CalDAVBackend = caldavserver.NewBackend(a.Calendars, a.Events, a.Attachments, cfg.MaxAttachmentSize, cfg.MaxAttachmentsPerEvent) diff --git a/server/internal/db/migrations/00010_task_lists.sql b/server/internal/db/migrations/00010_task_lists.sql new file mode 100644 index 0000000..b59230d --- /dev/null +++ b/server/internal/db/migrations/00010_task_lists.sql @@ -0,0 +1,31 @@ +-- +goose Up +-- task_lists is a Task List (#317, ADR-0083, CONTEXT.md's Task List entry): +-- a named, private container of Tasks belonging to one User within one +-- Workspace. Deliberately not a Calendar (ADR-0083's "why a Task List is +-- not a Calendar") — no Access, Share, Role, Source or Exposure, so the +-- read path is WHERE user_id = ? AND workspace_id = ? and "can B see A's +-- Task List?" is not a question the code can ask. Scoped and shaped after +-- calendar_sets (00009_calendar_sets.sql): both user_id and workspace_id, +-- cascading on both, ordered by created_at then id. +-- +-- is_default marks the one Task List a Task with no stated list goes to — +-- exactly one per (user_id, workspace_id), moved atomically by the service +-- layer (clear old, set new) rather than enforced here. Auto-created as +-- "Inbox" the moment a (User, Workspace) pair comes into being (Workspace +-- creation, Workspace Invite acceptance), inside the same transaction that +-- creates the pair (ADR-0018). +CREATE TABLE task_lists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT NOT NULL, + is_default BOOLEAN NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_task_lists_user_id ON task_lists(user_id); +CREATE INDEX idx_task_lists_workspace_id ON task_lists(workspace_id); + +-- +goose Down +DROP TABLE task_lists; diff --git a/server/internal/handlers/task_list.go b/server/internal/handlers/task_list.go new file mode 100644 index 0000000..f07745d --- /dev/null +++ b/server/internal/handlers/task_list.go @@ -0,0 +1,178 @@ +// task_list.go implements TaskListHandler: List/Create/Rename/Recolor/ +// SetDefault/Delete back the Tasks panel's Lists filter (#317, ADR-0083). A +// Task List is private outright, so every route resolves by the caller's own +// userID and the active workspaceID together and renders a mismatch as 404 — +// a Task List belonging to someone else is indistinguishable from one that +// does not exist. +package handlers + +import ( + "net/http" + + "github.com/XiovV/calich/server/internal/httpauth" + "github.com/XiovV/calich/server/internal/httpresponse" + "github.com/XiovV/calich/server/internal/repository" + "github.com/XiovV/calich/server/internal/service" +) + +type TaskListHandler struct { + taskLists *service.TaskListService +} + +func NewTaskListHandler(taskLists *service.TaskListService) *TaskListHandler { + return &TaskListHandler{taskLists: taskLists} +} + +type taskListResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + IsDefault bool `json:"isDefault"` +} + +func toTaskListResponse(l repository.TaskList) taskListResponse { + return taskListResponse{ID: l.ID, Name: l.Name, Color: l.Color, IsDefault: l.IsDefault} +} + +// taskListErrors renders the sentinels every TaskListService call can +// return: an empty name, an invalid color, an attempt to delete the default +// Task List, and repository.ErrNotFound for a Task List that doesn't exist, +// belongs to another User, or belongs to another Workspace — the three are +// indistinguishable by design. +var taskListErrors = []errorCase{ + {service.ErrInvalidTaskListName, badRequest(service.ErrInvalidTaskListName.Error())}, + {service.ErrInvalidTaskListColor, badRequest(service.ErrInvalidTaskListColor.Error())}, + {service.ErrCannotDeleteDefaultTaskList, badRequest(service.ErrCannotDeleteDefaultTaskList.Error())}, + {repository.ErrNotFound, notFound("task list not found")}, +} + +// List serves GET /api/task-lists: every Task List the caller owns in their +// active Workspace. +func (h *TaskListHandler) List(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + + lists, err := h.taskLists.ListForUser(r.Context(), userID, workspaceID) + if respondError(w, err, taskListErrors, "failed to list task lists") { + return + } + + response := make([]taskListResponse, len(lists)) + for i, l := range lists { + response[i] = toTaskListResponse(l) + } + + httpresponse.JSON(w, http.StatusOK, response) +} + +type createTaskListRequest struct { + Name string `json:"name"` + Color string `json:"color"` +} + +// Create makes a new, non-default Task List in the caller's active +// Workspace. Color is optional — a blank one auto-assigns a Swatch. +func (h *TaskListHandler) Create(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + + req, ok := decodeJSON[createTaskListRequest](w, r) + if !ok { + return + } + + list, err := h.taskLists.Create(r.Context(), userID, workspaceID, req.Name, req.Color) + if respondError(w, err, taskListErrors, "failed to create task list") { + return + } + + httpresponse.JSON(w, http.StatusCreated, toTaskListResponse(list)) +} + +type renameTaskListRequest struct { + Name string `json:"name"` +} + +// Rename changes a Task List's name. +func (h *TaskListHandler) Rename(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + id, ok := parseInt64Param(w, r, "id") + if !ok { + return + } + + req, ok := decodeJSON[renameTaskListRequest](w, r) + if !ok { + return + } + + list, err := h.taskLists.Rename(r.Context(), userID, workspaceID, id, req.Name) + if respondError(w, err, taskListErrors, "failed to rename task list") { + return + } + + httpresponse.JSON(w, http.StatusOK, toTaskListResponse(list)) +} + +type recolorTaskListRequest struct { + Color string `json:"color"` +} + +// Recolor changes a Task List's color. +func (h *TaskListHandler) Recolor(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + id, ok := parseInt64Param(w, r, "id") + if !ok { + return + } + + req, ok := decodeJSON[recolorTaskListRequest](w, r) + if !ok { + return + } + + list, err := h.taskLists.Recolor(r.Context(), userID, workspaceID, id, req.Color) + if respondError(w, err, taskListErrors, "failed to recolor task list") { + return + } + + httpresponse.JSON(w, http.StatusOK, toTaskListResponse(list)) +} + +// SetDefault serves PUT /api/task-lists/{id}/default: promotes a Task List +// to be the caller's default within their active Workspace, atomically +// clearing whichever Task List held the flag before (ADR-0083). +func (h *TaskListHandler) SetDefault(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + id, ok := parseInt64Param(w, r, "id") + if !ok { + return + } + + list, err := h.taskLists.SetDefault(r.Context(), userID, workspaceID, id) + if respondError(w, err, taskListErrors, "failed to set default task list") { + return + } + + httpresponse.JSON(w, http.StatusOK, toTaskListResponse(list)) +} + +// Delete removes a Task List outright, refusing while it holds the default +// flag (ADR-0083). +func (h *TaskListHandler) Delete(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + id, ok := parseInt64Param(w, r, "id") + if !ok { + return + } + + if err := h.taskLists.Delete(r.Context(), userID, workspaceID, id); respondError(w, err, taskListErrors, "failed to delete task list") { + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/internal/handlers/task_list_test.go b/server/internal/handlers/task_list_test.go new file mode 100644 index 0000000..a50b650 --- /dev/null +++ b/server/internal/handlers/task_list_test.go @@ -0,0 +1,621 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/XiovV/calich/server/internal/apptest" + "github.com/XiovV/calich/server/internal/httpauth" + "github.com/XiovV/calich/server/internal/service" +) + +// taskListHandlerTestServer bundles the HTTP surface the Tasks panel's +// Lists filter needs (#317, ADR-0083): Register (to mint real Users, each +// with their own Workspace and auto-provisioned Inbox), the Workspace +// Invite issue/accept routes (to exercise Inbox provisioning on that other +// call path), and the /api/task-lists routes, gated by RequireWorkspace +// exactly like router.New wires them. +type taskListHandlerTestServer struct { + srv *httptest.Server + graph *service.Graph + workspaces *service.WorkspaceService +} + +func newTaskListHandlerTestServer(t *testing.T) *taskListHandlerTestServer { + t.Helper() + + // No bootstrap account, and ENABLE_SIGNUPS on: every User here registers + // through the HTTP surface. + cfg := apptest.Config(t) + cfg.InitialName, cfg.InitialEmail, cfg.InitialPassword = "", "", "" + cfg.EnableSignups = true + g := newTestGraphWithConfig(t, cfg) + + workspaces := g.Workspaces + auth := g.Auth + taskLists := g.TaskLists + + authHandler := NewAuthHandler(auth, g.RateLimiter, false, false, false, true) + workspaceHandler := NewWorkspaceHandler(workspaces) + taskListHandler := NewTaskListHandler(taskLists) + + r := chi.NewRouter() + r.Post("/api/auth/register", authHandler.Register) + // Workspace Invite accept (ADR-0044): the new-account path is public, + // the existing-account path requires the caller already be logged in — + // both are exercised here to prove Inbox provisioning on both accept + // paths, not just Workspace creation. + r.Post("/api/auth/accept-workspace-invite", authHandler.AcceptWorkspaceInvite) + r.With(httpauth.RequireAuth(auth)).Post("/api/auth/accept-workspace-invite/join", authHandler.JoinWorkspaceInvite) + + r.Route("/api/workspaces", func(r chi.Router) { + r.Use(httpauth.RequireAuth(auth)) + r.Use(httpauth.RequireEnabledUser(auth)) + + r.Post("/{id}/invites", workspaceHandler.CreateInvite) + }) + + r.Route("/api/task-lists", func(r chi.Router) { + r.Use(httpauth.RequireAuth(auth)) + r.Use(httpauth.RequireEnabledUser(auth)) + r.Use(httpauth.RequireWorkspace(workspaces)) + + r.Get("/", taskListHandler.List) + r.Post("/", taskListHandler.Create) + r.Patch("/{id}", taskListHandler.Rename) + r.Patch("/{id}/color", taskListHandler.Recolor) + r.Put("/{id}/default", taskListHandler.SetDefault) + r.Delete("/{id}", taskListHandler.Delete) + }) + + srv := httptest.NewServer(r) + t.Cleanup(srv.Close) + + return &taskListHandlerTestServer{srv: srv, graph: g, workspaces: workspaces} +} + +func (s *taskListHandlerTestServer) register(t *testing.T, username string) (accessToken string, userID, workspaceID int64) { + t.Helper() + ctx := context.Background() + + body, err := json.Marshal(registerRequest{Name: username, Email: username + "@example.com", Password: "hunter22"}) + if err != nil { + t.Fatalf("marshal register request: %v", err) + } + resp, err := http.Post(s.srv.URL+"/api/auth/register", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("POST /api/auth/register: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 registering %s, got %d", username, resp.StatusCode) + } + var logged loginResponse + if err := json.NewDecoder(resp.Body).Decode(&logged); err != nil { + t.Fatalf("decode register response: %v", err) + } + + users := s.graph.UserRepo + user, err := users.GetByEmail(ctx, username+"@example.com") + if err != nil { + t.Fatalf("get %s: %v", username, err) + } + workspaces, err := s.workspaces.ListForUser(ctx, user.ID) + if err != nil || len(workspaces) != 1 { + t.Fatalf("list workspaces for %s: %v (%d)", username, err, len(workspaces)) + } + + return logged.AccessToken, user.ID, workspaces[0].ID +} + +// createInvite issues a Workspace Invite for email inside workspaceID, on +// ownerToken's behalf, and returns its plaintext token. +func (s *taskListHandlerTestServer) createInvite(t *testing.T, ownerToken string, workspaceID int64, email string) string { + t.Helper() + + body, err := json.Marshal(createWorkspaceInviteRequest{Email: email}) + if err != nil { + t.Fatalf("marshal create invite request: %v", err) + } + req, err := http.NewRequest(http.MethodPost, s.srv.URL+"/api/workspaces/"+strconv.FormatInt(workspaceID, 10)+"/invites", bytes.NewReader(body)) + if err != nil { + t.Fatalf("build create invite request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+ownerToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST create invite: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 creating invite, got %d", resp.StatusCode) + } + + var invite workspaceInviteResponse + if err := json.NewDecoder(resp.Body).Decode(&invite); err != nil { + t.Fatalf("decode create invite response: %v", err) + } + return invite.Token +} + +// acceptNewAccount accepts inviteToken via the public new-account path, +// creating a brand-new User, and returns their access token. +func (s *taskListHandlerTestServer) acceptNewAccount(t *testing.T, inviteToken, name, password string) string { + t.Helper() + + body, err := json.Marshal(acceptWorkspaceInviteRequest{Token: inviteToken, Name: name, Password: password}) + if err != nil { + t.Fatalf("marshal accept invite request: %v", err) + } + resp, err := http.Post(s.srv.URL+"/api/auth/accept-workspace-invite", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("POST accept invite (new account): %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 accepting invite (new account), got %d", resp.StatusCode) + } + + var logged loginResponse + if err := json.NewDecoder(resp.Body).Decode(&logged); err != nil { + t.Fatalf("decode accept invite response: %v", err) + } + return logged.AccessToken +} + +// acceptExisting accepts inviteToken via the authenticated existing-account +// path, on accessToken's behalf. +func (s *taskListHandlerTestServer) acceptExisting(t *testing.T, accessToken, inviteToken string) { + t.Helper() + + body, err := json.Marshal(joinWorkspaceInviteRequest{Token: inviteToken}) + if err != nil { + t.Fatalf("marshal join invite request: %v", err) + } + req, err := http.NewRequest(http.MethodPost, s.srv.URL+"/api/auth/accept-workspace-invite/join", bytes.NewReader(body)) + if err != nil { + t.Fatalf("build join invite request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST accept invite (existing account): %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 accepting invite (existing account), got %d", resp.StatusCode) + } +} + +// createTaskList creates a Task List named name in color for accessToken in +// workspaceID and returns the decoded response, failing the test on +// anything but 201. +func (s *taskListHandlerTestServer) createTaskList(t *testing.T, accessToken string, workspaceID int64, name, color string) taskListResponse { + t.Helper() + + resp := s.do(t, http.MethodPost, "/api/task-lists/", accessToken, workspaceID, createTaskListRequest{Name: name, Color: color}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 creating task list, got %d", resp.StatusCode) + } + var list taskListResponse + if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { + t.Fatalf("decode create task list response: %v", err) + } + return list +} + +// listTaskLists GETs accessToken's Task Lists in workspaceID. +func (s *taskListHandlerTestServer) listTaskLists(t *testing.T, accessToken string, workspaceID int64) []taskListResponse { + t.Helper() + + resp := s.do(t, http.MethodGet, "/api/task-lists/", accessToken, workspaceID, nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 listing task lists, got %d", resp.StatusCode) + } + var list []taskListResponse + if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { + t.Fatalf("decode list task lists response: %v", err) + } + return list +} + +func (s *taskListHandlerTestServer) do(t *testing.T, method, path, accessToken string, workspaceID int64, body any) *http.Response { + t.Helper() + + var reader *bytes.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + reader = bytes.NewReader(b) + } else { + reader = bytes.NewReader(nil) + } + + req, err := http.NewRequest(method, s.srv.URL+path, reader) + if err != nil { + t.Fatalf("build request: %v", err) + } + if accessToken != "" { + req.Header.Set("Authorization", "Bearer "+accessToken) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Workspace-Id", strconv.FormatInt(workspaceID, 10)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + return resp +} + +func TestTaskListHandler_Register_ProvisionsDefaultInbox(t *testing.T) { + s := newTaskListHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + + lists := s.listTaskLists(t, token, workspaceID) + if len(lists) != 1 { + t.Fatalf("expected exactly one auto-provisioned task list, got %v", lists) + } + if lists[0].Name != "Inbox" || !lists[0].IsDefault { + t.Fatalf("expected a default Inbox, got %v", lists[0]) + } +} + +func TestTaskListHandler_WorkspaceInviteAcceptance_NewAccount_ProvisionsDefaultInbox(t *testing.T) { + s := newTaskListHandlerTestServer(t) + aliceToken, _, aliceWorkspaceID := s.register(t, "alice") + + inviteToken := s.createInvite(t, aliceToken, aliceWorkspaceID, "carol@example.com") + carolToken := s.acceptNewAccount(t, inviteToken, "Carol", "hunter22") + + lists := s.listTaskLists(t, carolToken, aliceWorkspaceID) + if len(lists) != 1 { + t.Fatalf("expected exactly one auto-provisioned task list, got %v", lists) + } + if lists[0].Name != "Inbox" || !lists[0].IsDefault { + t.Fatalf("expected a default Inbox, got %v", lists[0]) + } +} + +func TestTaskListHandler_WorkspaceInviteAcceptance_ExistingAccount_ProvisionsDefaultInbox(t *testing.T) { + s := newTaskListHandlerTestServer(t) + aliceToken, _, aliceWorkspaceID := s.register(t, "alice") + bobToken, _, bobWorkspaceID := s.register(t, "bob") + + inviteToken := s.createInvite(t, aliceToken, aliceWorkspaceID, "bob@example.com") + s.acceptExisting(t, bobToken, inviteToken) + + // bob's own Workspace keeps its own Inbox, untouched. + bobOwnLists := s.listTaskLists(t, bobToken, bobWorkspaceID) + if len(bobOwnLists) != 1 || bobOwnLists[0].Name != "Inbox" || !bobOwnLists[0].IsDefault { + t.Fatalf("expected bob's own workspace to still have its own default Inbox, got %v", bobOwnLists) + } + + // bob also gets his own, independent default Inbox inside alice's + // Workspace — provisioned the moment his (User, Workspace) membership + // there came into being. + bobListsInAlice := s.listTaskLists(t, bobToken, aliceWorkspaceID) + if len(bobListsInAlice) != 1 || bobListsInAlice[0].Name != "Inbox" || !bobListsInAlice[0].IsDefault { + t.Fatalf("expected bob to get a default Inbox in alice's workspace, got %v", bobListsInAlice) + } +} + +func TestTaskListHandler_Create_AnyMemberCanCreate(t *testing.T) { + s := newTaskListHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + + list := s.createTaskList(t, token, workspaceID, "Work", "#12809CFF") + if list.Name != "Work" { + t.Fatalf("expected name %q, got %q", "Work", list.Name) + } + if list.Color != "#12809CFF" { + t.Fatalf("expected color %q, got %q", "#12809CFF", list.Color) + } + if list.IsDefault { + t.Fatalf("expected a freshly created task list to not be default") + } +} + +func TestTaskListHandler_Create_BlankColorAutoAssignsASwatch(t *testing.T) { + s := newTaskListHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + + list := s.createTaskList(t, token, workspaceID, "Work", "") + if list.Color == "" { + t.Fatalf("expected a non-empty auto-assigned color") + } +} + +func TestTaskListHandler_Create_EmptyNameRejected(t *testing.T) { + s := newTaskListHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + + resp := s.do(t, http.MethodPost, "/api/task-lists/", token, workspaceID, createTaskListRequest{Name: " ", Color: "#12809CFF"}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestTaskListHandler_Create_InvalidColorRejected(t *testing.T) { + s := newTaskListHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + + resp := s.do(t, http.MethodPost, "/api/task-lists/", token, workspaceID, createTaskListRequest{Name: "Work", Color: "not-a-color"}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestTaskListHandler_RenameAndRecolor(t *testing.T) { + s := newTaskListHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + + list := s.createTaskList(t, token, workspaceID, "Work", "#12809CFF") + + renameResp := s.do(t, http.MethodPatch, "/api/task-lists/"+strconv.FormatInt(list.ID, 10), token, workspaceID, renameTaskListRequest{Name: "Weekend"}) + defer renameResp.Body.Close() + if renameResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 renaming, got %d", renameResp.StatusCode) + } + var renamed taskListResponse + if err := json.NewDecoder(renameResp.Body).Decode(&renamed); err != nil { + t.Fatalf("decode rename response: %v", err) + } + if renamed.Name != "Weekend" { + t.Fatalf("expected name %q, got %q", "Weekend", renamed.Name) + } + + recolorResp := s.do(t, http.MethodPatch, "/api/task-lists/"+strconv.FormatInt(list.ID, 10)+"/color", token, workspaceID, recolorTaskListRequest{Color: "#E2483DFF"}) + defer recolorResp.Body.Close() + if recolorResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 recoloring, got %d", recolorResp.StatusCode) + } + var recolored taskListResponse + if err := json.NewDecoder(recolorResp.Body).Decode(&recolored); err != nil { + t.Fatalf("decode recolor response: %v", err) + } + if recolored.Color != "#E2483DFF" { + t.Fatalf("expected color %q, got %q", "#E2483DFF", recolored.Color) + } +} + +func TestTaskListHandler_List_ScopedToWorkspace(t *testing.T) { + s := newTaskListHandlerTestServer(t) + token, userID, firstWorkspaceID := s.register(t, "alice") + + s.createTaskList(t, token, firstWorkspaceID, "Work", "#12809CFF") + + secondWorkspace, err := s.graph.Workspaces.CreateForOwner(context.Background(), userID, "Second workspace") + if err != nil { + t.Fatalf("create second workspace: %v", err) + } + + // The second Workspace has its own auto-provisioned Inbox, and nothing + // from the first. + lists := s.listTaskLists(t, token, secondWorkspace.ID) + if len(lists) != 1 || lists[0].Name != "Inbox" { + t.Fatalf("expected only the second workspace's own Inbox, got %v", lists) + } +} + +func TestTaskListHandler_SecondUserCannotReadRenameRecolorOrDeleteFirstsTaskList(t *testing.T) { + s := newTaskListHandlerTestServer(t) + aliceToken, _, workspaceID := s.register(t, "alice") + bobToken, bobID, _ := s.register(t, "bob") + // bob is not a Member of alice's Workspace, but that's irrelevant here: + // even a Member of the same Workspace must not reach another User's + // private Task List, so add bob to alice's Workspace to prove privacy + // holds regardless of shared Workspace membership. + if _, err := s.graph.DB.Exec("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", workspaceID, bobID, "member"); err != nil { + t.Fatalf("add bob to alice's workspace: %v", err) + } + + list := s.createTaskList(t, aliceToken, workspaceID, "Work", "#12809CFF") + + renameResp := s.do(t, http.MethodPatch, "/api/task-lists/"+strconv.FormatInt(list.ID, 10), bobToken, workspaceID, renameTaskListRequest{Name: "Hijacked"}) + defer renameResp.Body.Close() + if renameResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 renaming, got %d", renameResp.StatusCode) + } + + recolorResp := s.do(t, http.MethodPatch, "/api/task-lists/"+strconv.FormatInt(list.ID, 10)+"/color", bobToken, workspaceID, recolorTaskListRequest{Color: "#E2483DFF"}) + defer recolorResp.Body.Close() + if recolorResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 recoloring, got %d", recolorResp.StatusCode) + } + + setDefaultResp := s.do(t, http.MethodPut, "/api/task-lists/"+strconv.FormatInt(list.ID, 10)+"/default", bobToken, workspaceID, nil) + defer setDefaultResp.Body.Close() + if setDefaultResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 setting default, got %d", setDefaultResp.StatusCode) + } + + deleteResp := s.do(t, http.MethodDelete, "/api/task-lists/"+strconv.FormatInt(list.ID, 10), bobToken, workspaceID, nil) + defer deleteResp.Body.Close() + if deleteResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 deleting, got %d", deleteResp.StatusCode) + } + + // alice's Task List survived every one of bob's attempts, untouched. + aliceLists := s.listTaskLists(t, aliceToken, workspaceID) + found := false + for _, l := range aliceLists { + if l.ID == list.ID && l.Name == "Work" { + found = true + } + } + if !found { + t.Fatalf("expected alice's task list to survive untouched, got %v", aliceLists) + } +} + +func TestTaskListHandler_SetDefault_PromotesAndClearsOldDefault(t *testing.T) { + s := newTaskListHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + + work := s.createTaskList(t, token, workspaceID, "Work", "#12809CFF") + + resp := s.do(t, http.MethodPut, "/api/task-lists/"+strconv.FormatInt(work.ID, 10)+"/default", token, workspaceID, nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 setting default, got %d", resp.StatusCode) + } + var promoted taskListResponse + if err := json.NewDecoder(resp.Body).Decode(&promoted); err != nil { + t.Fatalf("decode set-default response: %v", err) + } + if !promoted.IsDefault { + t.Fatalf("expected Work to be the new default") + } + + lists := s.listTaskLists(t, token, workspaceID) + defaultCount := 0 + var inboxIsDefault, workIsDefault bool + for _, l := range lists { + if l.IsDefault { + defaultCount++ + } + switch l.Name { + case "Inbox": + inboxIsDefault = l.IsDefault + case "Work": + workIsDefault = l.IsDefault + } + } + if defaultCount != 1 { + t.Fatalf("expected exactly one default task list, got %d in %v", defaultCount, lists) + } + if inboxIsDefault { + t.Fatalf("expected Inbox to no longer be default, got %v", lists) + } + if !workIsDefault { + t.Fatalf("expected Work to be default, got %v", lists) + } +} + +func TestTaskListHandler_Delete_RefusesWhileDefault(t *testing.T) { + s := newTaskListHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + + lists := s.listTaskLists(t, token, workspaceID) + inbox := lists[0] + if !inbox.IsDefault { + t.Fatalf("expected the auto-provisioned Inbox to be default, got %v", inbox) + } + + resp := s.do(t, http.MethodDelete, "/api/task-lists/"+strconv.FormatInt(inbox.ID, 10), token, workspaceID, nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 deleting the default task list, got %d", resp.StatusCode) + } + + // Still there. + survivors := s.listTaskLists(t, token, workspaceID) + if len(survivors) != 1 { + t.Fatalf("expected the default task list to survive the refused delete, got %v", survivors) + } +} + +func TestTaskListHandler_Delete_AllowedAfterPromotingAnotherToDefault(t *testing.T) { + s := newTaskListHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + + lists := s.listTaskLists(t, token, workspaceID) + inbox := lists[0] + + work := s.createTaskList(t, token, workspaceID, "Work", "#12809CFF") + + promoteResp := s.do(t, http.MethodPut, "/api/task-lists/"+strconv.FormatInt(work.ID, 10)+"/default", token, workspaceID, nil) + defer promoteResp.Body.Close() + if promoteResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 promoting Work, got %d", promoteResp.StatusCode) + } + + deleteResp := s.do(t, http.MethodDelete, "/api/task-lists/"+strconv.FormatInt(inbox.ID, 10), token, workspaceID, nil) + defer deleteResp.Body.Close() + if deleteResp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204 deleting the former default, got %d", deleteResp.StatusCode) + } + + survivors := s.listTaskLists(t, token, workspaceID) + if len(survivors) != 1 || survivors[0].Name != "Work" { + t.Fatalf("expected only Work to survive, got %v", survivors) + } +} + +// TestTaskListHandler_CascadesOnWorkspaceDeletion covers task_lists. +// workspace_id's ON DELETE CASCADE (ADR-0083, mirroring calendar_sets'). +func TestTaskListHandler_CascadesOnWorkspaceDeletion(t *testing.T) { + s := newTaskListHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + + lists := s.listTaskLists(t, token, workspaceID) + if len(lists) != 1 { + t.Fatalf("expected exactly the auto-provisioned Inbox, got %v", lists) + } + + if _, err := s.graph.DB.Exec("DELETE FROM workspaces WHERE id = ?", workspaceID); err != nil { + t.Fatalf("delete workspace: %v", err) + } + + var count int + if err := s.graph.DB.QueryRow("SELECT COUNT(*) FROM task_lists WHERE workspace_id = ?", workspaceID).Scan(&count); err != nil { + t.Fatalf("count task lists: %v", err) + } + if count != 0 { + t.Fatalf("expected deleting the workspace to cascade every task list in it, got %d remaining", count) + } +} + +// TestTaskListHandler_CascadesOnUserDeletion covers task_lists.user_id's ON +// DELETE CASCADE (ADR-0083), isolated from the workspace_id cascade above: +// bob's own Workspace (and its Inbox) is removed first — the order +// cleanupFailedRegistration already uses, since workspaces.owner_user_id +// carries no ON DELETE behaviour — leaving only the Inbox +// AcceptWorkspaceInviteExisting provisioned for him inside alice's +// Workspace, which only the user_id cascade can remove. +func TestTaskListHandler_CascadesOnUserDeletion(t *testing.T) { + s := newTaskListHandlerTestServer(t) + aliceToken, _, aliceWorkspaceID := s.register(t, "alice") + bobToken, bobID, bobWorkspaceID := s.register(t, "bob") + + inviteToken := s.createInvite(t, aliceToken, aliceWorkspaceID, "bob@example.com") + s.acceptExisting(t, bobToken, inviteToken) + + bobListsInAlice := s.listTaskLists(t, bobToken, aliceWorkspaceID) + if len(bobListsInAlice) != 1 { + t.Fatalf("expected bob to have his own Inbox in alice's workspace, got %v", bobListsInAlice) + } + + if _, err := s.graph.DB.Exec("DELETE FROM workspaces WHERE id = ?", bobWorkspaceID); err != nil { + t.Fatalf("delete bob's own workspace: %v", err) + } + if _, err := s.graph.DB.Exec("DELETE FROM users WHERE id = ?", bobID); err != nil { + t.Fatalf("delete bob: %v", err) + } + + var count int + if err := s.graph.DB.QueryRow("SELECT COUNT(*) FROM task_lists WHERE user_id = ?", bobID).Scan(&count); err != nil { + t.Fatalf("count task lists: %v", err) + } + if count != 0 { + t.Fatalf("expected deleting bob to cascade his remaining task list (the Inbox in alice's workspace), got %d remaining", count) + } +} diff --git a/server/internal/repository/task_list.go b/server/internal/repository/task_list.go new file mode 100644 index 0000000..f26a8cb --- /dev/null +++ b/server/internal/repository/task_list.go @@ -0,0 +1,172 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// TaskList is a Task List (ADR-0083): a named, private container of Tasks +// belonging to one User within one Workspace. Deliberately not a Calendar — +// no Access, Share, Role, Source or Exposure. +type TaskList struct { + ID int64 + UserID int64 + WorkspaceID int64 + Name string + Color string + IsDefault bool + CreatedAt time.Time +} + +type TaskListRepository struct { + db DBTX +} + +func NewTaskListRepository(db *sql.DB) *TaskListRepository { + return &TaskListRepository{db: db} +} + +// WithTx returns a copy of the repository bound to tx. +func (r *TaskListRepository) WithTx(tx *sql.Tx) *TaskListRepository { + return &TaskListRepository{db: tx} +} + +const taskListColumns = `id, user_id, workspace_id, name, color, is_default, created_at` + +// Create inserts a new Task List named name, in color, owned by userID +// inside workspaceID. isDefault is true only for the Inbox a (User, +// Workspace) pair is provisioned with (ADR-0083) — every other caller passes +// false, since promoting a Task List to default is SetDefault's job, not +// Create's. +func (r *TaskListRepository) Create(ctx context.Context, userID, workspaceID int64, name, color string, isDefault bool) (TaskList, error) { + res, err := r.db.ExecContext(ctx, + `INSERT INTO task_lists (user_id, workspace_id, name, color, is_default) VALUES (?, ?, ?, ?, ?)`, + userID, workspaceID, name, color, isDefault, + ) + if err != nil { + return TaskList{}, fmt.Errorf("insert task list: %w", err) + } + + id, err := res.LastInsertId() + if err != nil { + return TaskList{}, fmt.Errorf("get inserted task list id: %w", err) + } + + return r.GetByID(ctx, id, userID, workspaceID) +} + +// GetByID returns the Task List identified by id, scoped to userID and +// workspaceID together — a Task List belonging to another User, or to a +// different Workspace, is ErrNotFound rather than any error naming the +// mismatch, so the caller can never distinguish "not mine" from "doesn't +// exist". +func (r *TaskListRepository) GetByID(ctx context.Context, id, userID, workspaceID int64) (TaskList, error) { + row := r.db.QueryRowContext(ctx, + `SELECT `+taskListColumns+` FROM task_lists WHERE id = ? AND user_id = ? AND workspace_id = ?`, + id, userID, workspaceID, + ) + list, err := scanTaskListRow(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return TaskList{}, ErrNotFound + } + return TaskList{}, fmt.Errorf("scan task list: %w", err) + } + return list, nil +} + +// ListForUser returns every Task List userID owns inside workspaceID, +// ordered by when it was created. +func (r *TaskListRepository) ListForUser(ctx context.Context, userID, workspaceID int64) ([]TaskList, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT `+taskListColumns+` FROM task_lists WHERE user_id = ? AND workspace_id = ? ORDER BY created_at, id`, + userID, workspaceID, + ) + if err != nil { + return nil, fmt.Errorf("list task lists: %w", err) + } + return collectRows(rows, scanTaskListRow) +} + +func scanTaskListRow(row rowScanner) (TaskList, error) { + var l TaskList + err := row.Scan(&l.ID, &l.UserID, &l.WorkspaceID, &l.Name, &l.Color, &l.IsDefault, &l.CreatedAt) + return l, err +} + +// Rename updates id's name, scoped to userID and workspaceID like GetByID. +func (r *TaskListRepository) Rename(ctx context.Context, id, userID, workspaceID int64, name string) (TaskList, error) { + res, err := r.db.ExecContext(ctx, + `UPDATE task_lists SET name = ? WHERE id = ? AND user_id = ? AND workspace_id = ?`, + name, id, userID, workspaceID, + ) + if err != nil { + return TaskList{}, fmt.Errorf("rename task list: %w", err) + } + if err := requireAffected(res); err != nil { + return TaskList{}, err + } + return r.GetByID(ctx, id, userID, workspaceID) +} + +// Recolor updates id's color, scoped to userID and workspaceID like GetByID. +func (r *TaskListRepository) Recolor(ctx context.Context, id, userID, workspaceID int64, color string) (TaskList, error) { + res, err := r.db.ExecContext(ctx, + `UPDATE task_lists SET color = ? WHERE id = ? AND user_id = ? AND workspace_id = ?`, + color, id, userID, workspaceID, + ) + if err != nil { + return TaskList{}, fmt.Errorf("recolor task list: %w", err) + } + if err := requireAffected(res); err != nil { + return TaskList{}, err + } + return r.GetByID(ctx, id, userID, workspaceID) +} + +// ClearDefault unsets is_default on whichever Task List currently holds it +// for (userID, workspaceID) — SetDefault's first step, run in the same +// transaction as the second so a (User, Workspace) pair never has zero or +// two default Task Lists in between. A no-op (zero rows affected) is not an +// error: SetDefault's own caller might be promoting the very first Task +// List a User has, with no prior default to clear. +func (r *TaskListRepository) ClearDefault(ctx context.Context, userID, workspaceID int64) error { + if _, err := r.db.ExecContext(ctx, + `UPDATE task_lists SET is_default = 0 WHERE user_id = ? AND workspace_id = ? AND is_default = 1`, + userID, workspaceID, + ); err != nil { + return fmt.Errorf("clear default task list: %w", err) + } + return nil +} + +// SetDefaultFlag sets is_default on id, scoped to userID and workspaceID +// like GetByID — SetDefault's second step, after ClearDefault. +func (r *TaskListRepository) SetDefaultFlag(ctx context.Context, id, userID, workspaceID int64) error { + res, err := r.db.ExecContext(ctx, + `UPDATE task_lists SET is_default = 1 WHERE id = ? AND user_id = ? AND workspace_id = ?`, + id, userID, workspaceID, + ) + if err != nil { + return fmt.Errorf("set default task list: %w", err) + } + return requireAffected(res) +} + +// Delete removes id outright, scoped to userID and workspaceID like GetByID. +// Refusing to delete a Task List that still holds the default flag is +// TaskListService's job, checked before this is ever called — this method +// itself doesn't inspect is_default. +func (r *TaskListRepository) Delete(ctx context.Context, id, userID, workspaceID int64) error { + res, err := r.db.ExecContext(ctx, + `DELETE FROM task_lists WHERE id = ? AND user_id = ? AND workspace_id = ?`, + id, userID, workspaceID, + ) + if err != nil { + return fmt.Errorf("delete task list: %w", err) + } + return requireAffected(res) +} diff --git a/server/internal/router/router.go b/server/internal/router/router.go index 50b64de..c45bdfa 100644 --- a/server/internal/router/router.go +++ b/server/internal/router/router.go @@ -15,7 +15,7 @@ import ( "github.com/XiovV/calich/server/internal/static" ) -func New(logger *slog.Logger, authHandler *handlers.AuthHandler, calendarHandler *handlers.CalendarHandler, eventHandler *handlers.EventHandler, attachmentHandler *handlers.AttachmentHandler, notificationHandler *handlers.NotificationHandler, appPasswordHandler *handlers.AppPasswordHandler, accountHandler *handlers.AccountHandler, userHandler *handlers.UserHandler, workspaceHandler *handlers.WorkspaceHandler, groupHandler *handlers.GroupHandler, calendarSetHandler *handlers.CalendarSetHandler, connectionHandler *handlers.ConnectionHandler, calDAVHandler http.Handler, authenticator httpauth.Authenticator, activeUserChecker httpauth.ActiveUserChecker, calDAVAuthenticator httpauth.CalDAVAuthenticator, calDAVRateLimiter httpauth.CalDAVRateLimiter, enabledChecker httpauth.DisabledChecker, workspaceMembershipChecker httpauth.WorkspaceMembershipChecker) (http.Handler, error) { +func New(logger *slog.Logger, authHandler *handlers.AuthHandler, calendarHandler *handlers.CalendarHandler, eventHandler *handlers.EventHandler, attachmentHandler *handlers.AttachmentHandler, notificationHandler *handlers.NotificationHandler, appPasswordHandler *handlers.AppPasswordHandler, accountHandler *handlers.AccountHandler, userHandler *handlers.UserHandler, workspaceHandler *handlers.WorkspaceHandler, groupHandler *handlers.GroupHandler, calendarSetHandler *handlers.CalendarSetHandler, taskListHandler *handlers.TaskListHandler, connectionHandler *handlers.ConnectionHandler, calDAVHandler http.Handler, authenticator httpauth.Authenticator, activeUserChecker httpauth.ActiveUserChecker, calDAVAuthenticator httpauth.CalDAVAuthenticator, calDAVRateLimiter httpauth.CalDAVRateLimiter, enabledChecker httpauth.DisabledChecker, workspaceMembershipChecker httpauth.WorkspaceMembershipChecker) (http.Handler, error) { r := chi.NewRouter() r.Use(requestLogger(logger)) r.Use(middleware.Recoverer) @@ -267,6 +267,25 @@ func New(logger *slog.Logger, authHandler *handlers.AuthHandler, calendarHandler r.Delete("/{id}/calendars/{calendarId}", calendarSetHandler.RemoveCalendar) }) + // Task Lists (#317, ADR-0083): the Tasks panel's Lists filter, gated + // the same as Calendar Sets. Private outright — TaskListService + // itself resolves every Task List by (id, caller, active workspace) + // and refuses with ErrNotFound rather than any authority check, so + // no extra middleware gate is needed beyond RequireWorkspace. + r.Route("/task-lists", func(r chi.Router) { + r.Use(httpauth.RequireAuth(authenticator)) + r.Use(httpauth.RequireActiveUser(activeUserChecker)) + r.Use(httpauth.RequireEnabledUser(enabledChecker)) + r.Use(httpauth.RequireWorkspace(workspaceMembershipChecker)) + + r.Get("/", taskListHandler.List) + r.Post("/", taskListHandler.Create) + r.Patch("/{id}", taskListHandler.Rename) + r.Patch("/{id}/color", taskListHandler.Recolor) + r.Put("/{id}/default", taskListHandler.SetDefault) + r.Delete("/{id}", taskListHandler.Delete) + }) + // Connections (#285, ADR-0050, ADR-0051): Callback sits outside // RequireAuth on purpose — Google's redirect back is an // unauthenticated top-level navigation carrying neither an diff --git a/server/internal/router/router_test.go b/server/internal/router/router_test.go index 511e15f..803e61b 100644 --- a/server/internal/router/router_test.go +++ b/server/internal/router/router_test.go @@ -29,7 +29,7 @@ func newTestRouter(t *testing.T) http.Handler { t.Fatalf("bootstrap: %v", err) } - r, err := New(slog.New(slog.NewTextHandler(io.Discard, nil)), a.AuthHandler, a.CalendarHandler, a.EventHandler, a.AttachmentHandler, a.NotificationHandler, a.AppPasswordHandler, a.AccountHandler, a.UserHandler, a.WorkspaceHandler, a.GroupHandler, a.CalendarSetHandler, a.ConnectionHandler, a.CalDAVHandler, a.Auth, a.Auth, a.AppPasswords, a.RateLimiter, a.Auth, a.Workspaces) + r, err := New(slog.New(slog.NewTextHandler(io.Discard, nil)), a.AuthHandler, a.CalendarHandler, a.EventHandler, a.AttachmentHandler, a.NotificationHandler, a.AppPasswordHandler, a.AccountHandler, a.UserHandler, a.WorkspaceHandler, a.GroupHandler, a.CalendarSetHandler, a.TaskListHandler, a.ConnectionHandler, a.CalDAVHandler, a.Auth, a.Auth, a.AppPasswords, a.RateLimiter, a.Auth, a.Workspaces) if err != nil { t.Fatalf("New: %v", err) } diff --git a/server/internal/service/graph.go b/server/internal/service/graph.go index 9920e1e..731655b 100644 --- a/server/internal/service/graph.go +++ b/server/internal/service/graph.go @@ -66,6 +66,7 @@ type Graph struct { WorkspaceInviteRepo *repository.WorkspaceInviteRepository GroupRepo *repository.GroupRepository CalendarSetRepo *repository.CalendarSetRepository + TaskListRepo *repository.TaskListRepository NotificationRepo *repository.NotificationRepository AppPasswordRepo *repository.AppPasswordRepository FiredReminderRepo *repository.FiredReminderRepository @@ -92,6 +93,7 @@ type Graph struct { Events *EventService Groups *GroupService CalendarSets *CalendarSetService + TaskLists *TaskListService Imports *ImportService Notifications *NotificationService Subscriptions *SubscribeService @@ -219,6 +221,7 @@ func NewGraph(sqlDB *sql.DB, cfg config.Config, opts ...GraphOption) (*Graph, er WorkspaceInviteRepo: repository.NewWorkspaceInviteRepository(sqlDB), GroupRepo: repository.NewGroupRepository(sqlDB), CalendarSetRepo: repository.NewCalendarSetRepository(sqlDB), + TaskListRepo: repository.NewTaskListRepository(sqlDB), NotificationRepo: repository.NewNotificationRepository(sqlDB), AppPasswordRepo: repository.NewAppPasswordRepository(sqlDB), FiredReminderRepo: repository.NewFiredReminderRepository(sqlDB), @@ -227,7 +230,8 @@ func NewGraph(sqlDB *sql.DB, cfg config.Config, opts ...GraphOption) (*Graph, er g.RateLimitRepo = repository.NewRateLimitAttemptRepository(sqlDB) g.RateLimiter = NewAuthRateLimiter(g.RateLimitRepo, cfg.AuthRateLimitPerEmail, cfg.AuthRateLimitPerIP, cfg.RegisterRateLimitPerIP) - g.Workspaces = NewWorkspaceService(sqlDB, g.WorkspaceRepo, g.WorkspaceInviteRepo, g.CalendarRepo, g.ShareRepo) + g.Workspaces = NewWorkspaceService(sqlDB, g.WorkspaceRepo, g.WorkspaceInviteRepo, g.CalendarRepo, g.ShareRepo, g.TaskListRepo) + g.TaskLists = NewTaskListService(sqlDB, g.TaskListRepo) g.Groups = NewGroupService(g.GroupRepo, g.WorkspaceRepo) g.Calendars = NewCalendarService(sqlDB, g.CalendarRepo, g.SourceRepo, g.ShareRepo, g.UserRepo, g.EventReminderRepo, g.DefaultReminderRepo, g.ExplicitReminderRepo, g.ColorOverrideRepo, g.ExposureRepo, g.WorkspaceRepo, g.GroupShareRepo, g.GroupRepo) // CalendarSets needs Calendars for AddCalendar's Access check (#302, diff --git a/server/internal/service/task_list.go b/server/internal/service/task_list.go new file mode 100644 index 0000000..70de97c --- /dev/null +++ b/server/internal/service/task_list.go @@ -0,0 +1,173 @@ +package service + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/XiovV/calich/server/internal/repository" +) + +var ( + // ErrInvalidTaskListName is returned by TaskListService.Create and Rename + // when name is empty. + ErrInvalidTaskListName = errors.New("task list name must not be empty") + + // ErrInvalidTaskListColor is returned by Create and Recolor when color is + // non-empty but not a valid hex color. + ErrInvalidTaskListColor = errors.New("invalid task list color") + + // ErrCannotDeleteDefaultTaskList is returned by Delete when id still + // holds the default flag (ADR-0083) — promote another Task List to + // default first. + ErrCannotDeleteDefaultTaskList = errors.New("cannot delete the default task list") +) + +// TaskListService creates, lists, renames, recolors, promotes-to-default and +// deletes Task Lists (ADR-0083). Private outright — there is no sharing +// mechanism and no Role, so unlike GroupService this performs no +// requireWorkspaceOwnerOrAdmin-style authority check; every method is +// scoped to the caller's own userID and workspaceID by the repository call +// itself, and a Task List belonging to someone else surfaces as +// repository.ErrNotFound rather than any distinguishable "forbidden". +type TaskListService struct { + db *sql.DB + taskLists *repository.TaskListRepository +} + +func NewTaskListService(db *sql.DB, taskLists *repository.TaskListRepository) *TaskListService { + return &TaskListService{db: db, taskLists: taskLists} +} + +// ListForUser returns every Task List userID owns inside workspaceID. +func (s *TaskListService) ListForUser(ctx context.Context, userID, workspaceID int64) ([]repository.TaskList, error) { + lists, err := s.taskLists.ListForUser(ctx, userID, workspaceID) + if err != nil { + return nil, fmt.Errorf("list task lists: %w", err) + } + return lists, nil +} + +// Create makes a new, non-default Task List named name inside workspaceID, +// owned by userID. color is normalized via NormalizeColor if given; an empty +// color instead auto-assigns the first Swatch absent from the caller's +// existing Task Lists in this Workspace, mirroring Calendar's own +// pickFreeColor (ADR-0057) — a "New list" flow that only asks for a name +// still gets a distinguishable colour. +func (s *TaskListService) Create(ctx context.Context, userID, workspaceID int64, name, color string) (repository.TaskList, error) { + name = strings.TrimSpace(name) + if name == "" { + return repository.TaskList{}, ErrInvalidTaskListName + } + + resolved, err := s.resolveColor(ctx, userID, workspaceID, color) + if err != nil { + return repository.TaskList{}, err + } + + list, err := s.taskLists.Create(ctx, userID, workspaceID, name, resolved, false) + if err != nil { + return repository.TaskList{}, fmt.Errorf("create task list: %w", err) + } + return list, nil +} + +// resolveColor normalizes an explicit color, or — when color is blank — +// picks the first Swatch not already used by one of the caller's own Task +// Lists in workspaceID. +func (s *TaskListService) resolveColor(ctx context.Context, userID, workspaceID int64, color string) (string, error) { + if strings.TrimSpace(color) == "" { + existing, err := s.taskLists.ListForUser(ctx, userID, workspaceID) + if err != nil { + return "", fmt.Errorf("list task lists: %w", err) + } + used := make([]string, len(existing)) + for i, l := range existing { + used[i] = l.Color + } + return pickFreeColor(used), nil + } + + normalized, ok := NormalizeColor(color) + if !ok { + return "", ErrInvalidTaskListColor + } + return normalized, nil +} + +// Rename changes id's name, scoped to userID and workspaceID. +func (s *TaskListService) Rename(ctx context.Context, userID, workspaceID, id int64, name string) (repository.TaskList, error) { + name = strings.TrimSpace(name) + if name == "" { + return repository.TaskList{}, ErrInvalidTaskListName + } + + list, err := s.taskLists.Rename(ctx, id, userID, workspaceID, name) + if err != nil { + return repository.TaskList{}, fmt.Errorf("rename task list: %w", err) + } + return list, nil +} + +// Recolor changes id's color, scoped to userID and workspaceID. +func (s *TaskListService) Recolor(ctx context.Context, userID, workspaceID, id int64, color string) (repository.TaskList, error) { + normalized, ok := NormalizeColor(color) + if !ok { + return repository.TaskList{}, ErrInvalidTaskListColor + } + + list, err := s.taskLists.Recolor(ctx, id, userID, workspaceID, normalized) + if err != nil { + return repository.TaskList{}, fmt.Errorf("recolor task list: %w", err) + } + return list, nil +} + +// SetDefault promotes id to be userID's default Task List inside +// workspaceID, atomically clearing whichever Task List held the flag before +// (ADR-0083). GetByID confirms id is the caller's own before the +// transaction opens, so an id belonging to someone else — or nobody — +// surfaces as repository.ErrNotFound rather than silently clearing the +// caller's own current default for nothing. +func (s *TaskListService) SetDefault(ctx context.Context, userID, workspaceID, id int64) (repository.TaskList, error) { + if _, err := s.taskLists.GetByID(ctx, id, userID, workspaceID); err != nil { + return repository.TaskList{}, fmt.Errorf("get task list: %w", err) + } + + err := repository.WithTx(ctx, s.db, func(tx *sql.Tx) error { + txLists := s.taskLists.WithTx(tx) + + if err := txLists.ClearDefault(ctx, userID, workspaceID); err != nil { + return err + } + if err := txLists.SetDefaultFlag(ctx, id, userID, workspaceID); err != nil { + return err + } + return nil + }) + if err != nil { + return repository.TaskList{}, err + } + + return s.taskLists.GetByID(ctx, id, userID, workspaceID) +} + +// Delete removes id outright, scoped to userID and workspaceID, refusing +// while it holds the default flag (ADR-0083) — promote another Task List to +// default first via SetDefault. +func (s *TaskListService) Delete(ctx context.Context, userID, workspaceID, id int64) error { + list, err := s.taskLists.GetByID(ctx, id, userID, workspaceID) + if err != nil { + return fmt.Errorf("get task list: %w", err) + } + if list.IsDefault { + return ErrCannotDeleteDefaultTaskList + } + + if err := s.taskLists.Delete(ctx, id, userID, workspaceID); err != nil { + return fmt.Errorf("delete task list: %w", err) + } + return nil +} diff --git a/server/internal/service/workspace.go b/server/internal/service/workspace.go index 8141b08..0eb869c 100644 --- a/server/internal/service/workspace.go +++ b/server/internal/service/workspace.go @@ -21,10 +21,27 @@ type WorkspaceService struct { invites *repository.WorkspaceInviteRepository calendarRepo *repository.CalendarRepository shareRepo *repository.CalendarShareRepository + taskListRepo *repository.TaskListRepository } -func NewWorkspaceService(db *sql.DB, workspaces *repository.WorkspaceRepository, invites *repository.WorkspaceInviteRepository, calendarRepo *repository.CalendarRepository, shareRepo *repository.CalendarShareRepository) *WorkspaceService { - return &WorkspaceService{db: db, workspaces: workspaces, invites: invites, calendarRepo: calendarRepo, shareRepo: shareRepo} +func NewWorkspaceService(db *sql.DB, workspaces *repository.WorkspaceRepository, invites *repository.WorkspaceInviteRepository, calendarRepo *repository.CalendarRepository, shareRepo *repository.CalendarShareRepository, taskListRepo *repository.TaskListRepository) *WorkspaceService { + return &WorkspaceService{db: db, workspaces: workspaces, invites: invites, calendarRepo: calendarRepo, shareRepo: shareRepo, taskListRepo: taskListRepo} +} + +// createDefaultTaskListTx creates the Inbox Task List (ADR-0083) for a +// (User, Workspace) pair that just came into being, bound to the same +// transaction that created the pair — called from createForOwnerTx +// (Workspace creation, covering both Bootstrap and Register) and +// AddMemberInTx (Workspace Invite acceptance, covering both the new-account +// and existing-account paths), the two points such a pair is created. A +// brand-new pair has no other Task Lists yet, so the color is always the +// first Swatch (pickFreeColor with nothing taken) rather than needing a +// lookup. +func (s *WorkspaceService) createDefaultTaskListTx(ctx context.Context, tx *sql.Tx, userID, workspaceID int64) error { + if _, err := s.taskListRepo.WithTx(tx).Create(ctx, userID, workspaceID, "Inbox", pickFreeColor(nil), true); err != nil { + return fmt.Errorf("create default task list: %w", err) + } + return nil } // CreateForOwner creates a new Workspace named name and, atomically, adds @@ -66,6 +83,10 @@ func (s *WorkspaceService) createForOwnerTx(ctx context.Context, tx *sql.Tx, own return repository.Workspace{}, fmt.Errorf("add owner membership: %w", err) } + if err := s.createDefaultTaskListTx(ctx, tx, ownerUserID, workspace.ID); err != nil { + return repository.Workspace{}, err + } + return workspace, nil } @@ -95,9 +116,15 @@ func (s *WorkspaceService) GetByID(ctx context.Context, id int64) (repository.Wo // AddMemberInTx adds userID to workspaceID with role, bound to a transaction // the caller already holds open — the seam AuthService's Workspace-invite // accept paths use to fold creating (or resolving) the User, adding their -// Membership, and consuming the invite into one all-or-nothing unit. +// Membership, and consuming the invite into one all-or-nothing unit. Also +// provisions userID's Inbox Task List for workspaceID (ADR-0083) in the same +// transaction — a Workspace Invite accept is the other of the two points a +// (User, Workspace) pair comes into being, alongside createForOwnerTx. func (s *WorkspaceService) AddMemberInTx(ctx context.Context, tx *sql.Tx, workspaceID, userID int64, role string) error { - return s.workspaces.WithTx(tx).AddMember(ctx, workspaceID, userID, role) + if err := s.workspaces.WithTx(tx).AddMember(ctx, workspaceID, userID, role); err != nil { + return err + } + return s.createDefaultTaskListTx(ctx, tx, userID, workspaceID) } // IsMember reports whether userID belongs to workspaceID — the check diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index ab230af..dc5b1cf 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -3,6 +3,7 @@ import { startOfDay } from "date-fns"; import { Outlet } from "react-router"; import { TopBar } from "./TopBar"; import { Sidebar } from "./Sidebar"; +import { TasksPanel } from "./TasksPanel"; import { CalendarView } from "../../calendar-grid/CalendarView"; import { EventModal } from "../../calendar-grid/EventModal"; import { computeDefaultDraft, type DraftBlock } from "../../lib/gridTime"; @@ -21,6 +22,7 @@ export function AppShell() { const fetchEvents = useEventsStore((state) => state.fetchEvents); const fetchWorkspaces = useWorkspacesStore((state) => state.fetchWorkspaces); const activeWorkspaceId = useWorkspacesStore((state) => state.activeWorkspaceId); + const tasksPanelOpen = useShellStore((state) => state.tasksPanelOpen); useEffect(() => { fetchWorkspaces(); @@ -99,6 +101,11 @@ export function AppShell() { } /> + {tasksPanelOpen && ( + + )}
    {eventModalState?.mode === "create" && ( void; + color: string; + "aria-label": string; +} + +// TaskListToggle is the Lists filter's own show/hide control (#317, +// ADR-0083) — CalendarToggle's counterpart for a Task List rather than a +// Calendar, kept as its own small component instead of reusing +// CalendarToggle directly: a Task List is deliberately not a Calendar +// (ADR-0083), and naming the control after the wrong domain concept would +// misdescribe what it toggles even though the two happen to render +// identically today. +export function TaskListToggle({ + checked, + onCheckedChange, + color, + "aria-label": ariaLabel, +}: TaskListToggleProps) { + const opaqueColor = toOpaqueHex(color); + return ( + + + + + + ); +} diff --git a/src/components/layout/TaskListsFilter.test.tsx b/src/components/layout/TaskListsFilter.test.tsx new file mode 100644 index 0000000..208f7b0 --- /dev/null +++ b/src/components/layout/TaskListsFilter.test.tsx @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// Same convention as CalendarList.test.tsx: the *Api module is mocked, the +// stores are real. +vi.mock("../../lib/taskListsApi", () => ({ + taskListsApi: { + list: vi.fn(), + create: vi.fn(), + rename: vi.fn(), + recolor: vi.fn(), + setDefault: vi.fn(), + remove: vi.fn(), + }, +})); + +const { taskListsApi } = await import("../../lib/taskListsApi"); +const { useAuthStore } = await import("../../lib/authStore"); +const { useTaskListsStore } = await import("../../lib/taskListsStore"); +const { useShellStore } = await import("../../lib/shellStore"); +const { useWorkspacesStore } = await import("../../lib/workspacesStore"); +const { TaskListsFilter } = await import("./TaskListsFilter"); + +const inbox = { id: 1, name: "Inbox", color: "#E2483DFF", isDefault: true }; +const work = { id: 2, name: "Work", color: "#12809CFF", isDefault: false }; + +beforeEach(() => { + vi.clearAllMocks(); + useAuthStore.setState({ accessToken: "token-123" }); + useTaskListsStore.setState({ taskLists: [inbox, work] }); + useShellStore.setState({ + checkedTaskListIds: new Set([inbox.id, work.id]), + knownTaskListIds: new Set([inbox.id, work.id]), + }); + useWorkspacesStore.setState({ activeWorkspaceId: 7 }); + vi.mocked(taskListsApi.list).mockResolvedValue([inbox, work]); +}); + +async function openFilter() { + await userEvent.click(screen.getByRole("button", { name: "Lists filter" })); + await screen.findByRole("menu"); +} + +// #317, ADR-0083: the Lists filter shows every Task List with a colour +// checkbox and a checked-count, and offers "New list". +describe("TaskListsFilter", () => { + it("shows every task list with its own checkbox, checked by default", async () => { + render(); + + await openFilter(); + + expect(screen.getByText("Inbox")).toBeInTheDocument(); + expect(screen.getByText("Work")).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: "Inbox" })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: "Work" })).toBeChecked(); + }); + + it("shows a checked count on the trigger that updates as boxes are toggled", async () => { + render(); + expect(screen.getByRole("button", { name: "Lists filter" })).toHaveTextContent("2 of 2"); + + await openFilter(); + await userEvent.click(screen.getByRole("checkbox", { name: "Work" })); + + expect(screen.getByRole("button", { name: "Lists filter" })).toHaveTextContent("1 of 2"); + expect(screen.getByRole("checkbox", { name: "Work" })).not.toBeChecked(); + }); + + it("creates a new list via New list without leaving the panel, and checks it automatically", async () => { + vi.mocked(taskListsApi.create).mockResolvedValue({ + id: 3, + name: "Errands", + color: "#3F51B5FF", + isDefault: false, + }); + render(); + + await openFilter(); + // "New list" closes the dropdown (an ordinary Menu.Item click) and + // reveals the name field in the panel itself, not inside the Popup — a + // real text input fights a Menu's own Enter/Escape handling, so the + // create field deliberately lives outside it. The panel itself is never + // left throughout. + await userEvent.click(screen.getByRole("menuitem", { name: "New list" })); + const input = await screen.findByRole("textbox", { name: "New list name" }); + await userEvent.type(input, "Errands{Enter}"); + + await waitFor(() => expect(taskListsApi.create).toHaveBeenCalled()); + expect(taskListsApi.create).toHaveBeenCalledWith("token-123", "Errands", expect.any(String)); + expect(useTaskListsStore.getState().taskLists.some((l) => l.name === "Errands")).toBe(true); + expect(useShellStore.getState().checkedTaskListIds.has(3)).toBe(true); + + // Reopening the dropdown shows the newly created list alongside the + // existing ones. + await openFilter(); + expect(screen.getByText("Errands")).toBeInTheDocument(); + }); + + // #317, ADR-0083: Task Lists are scoped to the active Workspace, so + // switching Workspaces while the panel happens to be open must swap what + // it shows rather than leaving the previous Workspace's Lists in view. + it("refetches when the active workspace changes", async () => { + const otherWorkspaceList = { id: 9, name: "Groceries", color: "#8E44ADFF", isDefault: true }; + render(); + await waitFor(() => expect(taskListsApi.list).toHaveBeenCalledTimes(1)); + + vi.mocked(taskListsApi.list).mockResolvedValue([otherWorkspaceList]); + useWorkspacesStore.setState({ activeWorkspaceId: 42 }); + + await waitFor(() => expect(taskListsApi.list).toHaveBeenCalledTimes(2)); + await openFilter(); + expect(screen.getByText("Groceries")).toBeInTheDocument(); + expect(screen.queryByText("Inbox")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/layout/TaskListsFilter.tsx b/src/components/layout/TaskListsFilter.tsx new file mode 100644 index 0000000..e26c5a9 --- /dev/null +++ b/src/components/layout/TaskListsFilter.tsx @@ -0,0 +1,124 @@ +import { useEffect, useState } from "react"; +import { Menu } from "@base-ui/react/menu"; +import { ChevronDown, Plus } from "lucide-react"; +import { refetchTaskListsAndReconcile, useShellStore } from "../../lib/shellStore"; +import { useTaskListsStore } from "../../lib/taskListsStore"; +import { useWorkspacesStore } from "../../lib/workspacesStore"; +import { createTaskListCascade } from "../../lib/createTaskListCascade"; +import { getNextUnusedColor } from "../../lib/calendarColors"; +import { toast } from "../../lib/toast"; +import { TaskListToggle } from "./TaskListToggle"; + +const menuItemClasses = + "flex cursor-default items-center gap-2 px-3 py-1.5 text-body text-ink data-[highlighted]:bg-surface-hover"; + +// TaskListsFilter is the Tasks panel's Lists filter (#317, ADR-0083): every +// Task List the caller has in the active Workspace, each with its own +// colour checkbox, a checked-count on the trigger, and a "New list" entry +// that creates one without leaving the panel. The Task List counterpart to +// the sidebar's CalendarList, condensed into one dropdown the way +// CalendarSetSwitcher condenses Calendar Sets — except multi-select +// (several Lists can be checked at once) rather than CalendarSetSwitcher's +// single active choice, so each row is a plain checkbox row rather than a +// Menu.RadioItem. +// +// The name field for a new list renders as a plain input *below* the Menu +// rather than inside its Popup: a base-ui Menu owns Enter/Escape/typeahead +// for its own roving-focus navigation, which fights a real text input +// mounted inside it. Closing the dropdown when "New list" is clicked (the +// ordinary Menu.Item behaviour) and revealing the field in the panel below +// it still satisfies "without leaving the panel" — nothing here navigates +// away from TasksPanel. +export function TaskListsFilter() { + const taskLists = useTaskListsStore((state) => state.taskLists); + const checkedTaskListIds = useShellStore((state) => state.checkedTaskListIds); + const toggleTaskListChecked = useShellStore((state) => state.toggleTaskListChecked); + const [isCreating, setIsCreating] = useState(false); + const [draftName, setDraftName] = useState(""); + const activeWorkspaceId = useWorkspacesStore((state) => state.activeWorkspaceId); + + useEffect(() => { + // Refetches on mount and whenever the active Workspace changes (#317, + // ADR-0083, mirroring AppShell's own Calendar refetch effect) — Task + // Lists are scoped to the active Workspace, so switching Workspaces + // while the panel happens to be open must swap what this shows rather + // than leaving the previous Workspace's Lists on screen. A failure here + // costs the filter its Task List options and nothing else, same posture + // as CalendarSetSwitcher's own fetch effect. + if (activeWorkspaceId === null) return; + refetchTaskListsAndReconcile().catch(() => {}); + }, [activeWorkspaceId]); + + const checkedCount = taskLists.filter((list) => checkedTaskListIds.has(list.id)).length; + + async function handleCreate() { + const name = draftName.trim(); + setIsCreating(false); + setDraftName(""); + if (!name) return; + + try { + const usedColors = taskLists.map((list) => list.color); + await createTaskListCascade(name, getNextUnusedColor(usedColors)); + } catch { + toast.error("Couldn't create the list."); + } + } + + return ( +
    + + + Lists + + {checkedCount} of {taskLists.length} + + + + + + + {taskLists.map((taskList) => ( +
    + toggleTaskListChecked(taskList.id)} + color={taskList.color} + aria-label={taskList.name} + /> + {taskList.name} +
    + ))} +
    + setIsCreating(true)} className={menuItemClasses}> + + New list + + + + + + {isCreating && ( + setDraftName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") handleCreate(); + if (event.key === "Escape") { + setIsCreating(false); + setDraftName(""); + } + }} + onBlur={handleCreate} + placeholder="List name" + aria-label="New list name" + className="w-full rounded-shell-sm border border-border bg-surface px-2 py-1.5 text-body text-ink outline-none focus:ring-1 focus:ring-accent-ink" + /> + )} +
    + ); +} diff --git a/src/components/layout/TasksPanel.tsx b/src/components/layout/TasksPanel.tsx new file mode 100644 index 0000000..c2a6036 --- /dev/null +++ b/src/components/layout/TasksPanel.tsx @@ -0,0 +1,32 @@ +import { X } from "lucide-react"; +import { useShellStore } from "../../lib/shellStore"; +import { IconButton } from "../ui/IconButton"; +import { TaskListsFilter } from "./TaskListsFilter"; + +// TasksPanel is the right-hand panel holding a User's Tasks (#317, +// ADR-0083): toggled from the top bar's Tasks button, closed by default, +// squeezing the grid rather than overlaying it (AppShell renders it as a +// flex sibling of
    , exactly like the left Sidebar). This ticket +// delivers the containers (Task Lists) and the panel they live in — no Task +// exists yet, so the body below the Lists filter is a placeholder until a +// later ticket adds Tasks themselves. +export function TasksPanel() { + const setTasksPanelOpen = useShellStore((state) => state.setTasksPanelOpen); + + return ( +
    +
    +

    Tasks

    + setTasksPanelOpen(false)} + aria-label="Close Tasks panel" + > + + +
    + +

    No tasks yet.

    +
    + ); +} diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index 8ea69cf..fdc7bb8 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -1,4 +1,4 @@ -import { ChevronLeft, ChevronRight, Settings } from "lucide-react"; +import { ChevronLeft, ChevronRight, ListTodo, Settings } from "lucide-react"; import { useNavigate } from "react-router"; import { useShellStore } from "../../lib/shellStore"; import { useWeekStartsOn } from "../../hooks/useWeekStartsOn"; @@ -19,6 +19,8 @@ export function TopBar() { const selectedDate = useShellStore((state) => state.selectedDate); const activeView = useShellStore((state) => state.activeView); const setSelectedDate = useShellStore((state) => state.setSelectedDate); + const tasksPanelOpen = useShellStore((state) => state.tasksPanelOpen); + const setTasksPanelOpen = useShellStore((state) => state.setTasksPanelOpen); const weekStartsOn = useWeekStartsOn(); const version = useVersion(); @@ -68,6 +70,14 @@ export function TopBar() { + setTasksPanelOpen(!tasksPanelOpen)} + aria-label="Tasks" + aria-pressed={tasksPanelOpen} + className={tasksPanelOpen ? "bg-surface-hover text-ink" : undefined} + > + + navigate("/settings")} aria-label="Settings" diff --git a/src/lib/createTaskListCascade.test.ts b/src/lib/createTaskListCascade.test.ts new file mode 100644 index 0000000..308e25c --- /dev/null +++ b/src/lib/createTaskListCascade.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +vi.mock("./taskListsApi", () => ({ + taskListsApi: { create: vi.fn() }, +})); + +const { taskListsApi } = await import("./taskListsApi"); +const { useAuthStore } = await import("./authStore"); +const { useTaskListsStore } = await import("./taskListsStore"); +const { useShellStore } = await import("./shellStore"); +const { createTaskListCascade } = await import("./createTaskListCascade"); + +const work = { id: 2, name: "Work", color: "#12809CFF", isDefault: false }; + +beforeEach(() => { + vi.clearAllMocks(); + useAuthStore.setState({ accessToken: "token-123" }); + useTaskListsStore.setState({ taskLists: [] }); + useShellStore.setState({ + checkedTaskListIds: new Set(), + knownTaskListIds: new Set(), + }); +}); + +it("checks and marks the task list known once the create succeeds", async () => { + vi.mocked(taskListsApi.create).mockResolvedValue(work); + + await createTaskListCascade("Work", "#12809CFF"); + + expect(useShellStore.getState().checkedTaskListIds.has(work.id)).toBe(true); + expect(useShellStore.getState().knownTaskListIds.has(work.id)).toBe(true); +}); + +it("checks nothing when the create fails", async () => { + vi.mocked(taskListsApi.create).mockRejectedValue(new Error("network error")); + + await expect(createTaskListCascade("Work", "#12809CFF")).rejects.toThrow(); + + expect(useShellStore.getState().checkedTaskListIds.size).toBe(0); +}); + +it("survives a later reconcile without re-checking a task list the caller deliberately unchecked", async () => { + vi.mocked(taskListsApi.create).mockResolvedValue(work); + + await createTaskListCascade("Work", "#12809CFF"); + useShellStore.getState().toggleTaskListChecked(work.id); + + useShellStore.getState().reconcileCheckedTaskListIds([work.id]); + + expect(useShellStore.getState().checkedTaskListIds.has(work.id)).toBe(false); +}); diff --git a/src/lib/createTaskListCascade.ts b/src/lib/createTaskListCascade.ts new file mode 100644 index 0000000..5fc0c25 --- /dev/null +++ b/src/lib/createTaskListCascade.ts @@ -0,0 +1,17 @@ +import { useTaskListsStore } from "./taskListsStore"; +import { useShellStore } from "./shellStore"; + +// createTaskListCascade checks a Task List the moment it's created — an +// explicit act, not the "auto-check things I haven't seen" heuristic +// reconcileCheckedTaskListIds applies on refetch, so it must be checked +// immediately (#317, ADR-0083, mirroring createCalendarCascade.ts). +// Marking it known alongside checked (via addCheckedTaskListId, not +// toggleTaskListChecked) matters just as much: without it, the next +// reconcile treats the id as unseen and re-checks it even if the caller had +// since deliberately unchecked it. createTaskList throws on failure (unlike +// calendarsStore.addCalendar's own boolean-return-plus-rollback shape), so +// there is nothing to check or roll back until it has already succeeded. +export async function createTaskListCascade(name: string, color?: string): Promise { + const created = await useTaskListsStore.getState().createTaskList(name, color); + useShellStore.getState().addCheckedTaskListId(created.id); +} diff --git a/src/lib/reconcileCheckedIds.test.ts b/src/lib/reconcileCheckedIds.test.ts new file mode 100644 index 0000000..7b2d422 --- /dev/null +++ b/src/lib/reconcileCheckedIds.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { reconcileCheckedIds } from "./reconcileCheckedIds"; + +describe("reconcileCheckedIds", () => { + it("checks an id it has never seen before", () => { + const result = reconcileCheckedIds(new Set(), new Set(), ["a"]); + expect(result.checkedIds).toEqual(new Set(["a"])); + expect(result.knownIds).toEqual(new Set(["a"])); + }); + + it("leaves a previously-seen, deliberately-unchecked id unchecked", () => { + const result = reconcileCheckedIds(new Set(["a"]), new Set(), ["a"]); + expect(result.checkedIds).toEqual(new Set()); + }); + + it("leaves an already-checked id checked and adds a newly-seen one alongside it", () => { + const result = reconcileCheckedIds(new Set(["a"]), new Set(["a"]), ["a", "b"]); + expect(result.checkedIds).toEqual(new Set(["a", "b"])); + }); + + it("replaces knownIds with exactly the fresh snapshot, dropping ids no longer present", () => { + const result = reconcileCheckedIds(new Set(["a", "b"]), new Set(["a", "b"]), ["a"]); + expect(result.knownIds).toEqual(new Set(["a"])); + }); + + it("works over numeric ids the same as string ids", () => { + const result = reconcileCheckedIds(new Set([1]), new Set(), [1, 2]); + expect(result.checkedIds).toEqual(new Set([2])); + expect(result.knownIds).toEqual(new Set([1, 2])); + }); +}); diff --git a/src/lib/reconcileCheckedIds.ts b/src/lib/reconcileCheckedIds.ts new file mode 100644 index 0000000..8cc39cf --- /dev/null +++ b/src/lib/reconcileCheckedIds.ts @@ -0,0 +1,22 @@ +// reconcileCheckedIds auto-checks only an id absent from knownIds — one +// never seen before — so an id the caller deliberately unchecked stays +// unchecked across a refetch (#116). Extracted out of shellStore's own +// reconcileCheckedCalendarIds so the Tasks panel's Lists filter (#317, +// ADR-0083) can carry the identical rule over its own checked/known pair +// instead of duplicating it. Never removes an id itself: one that +// disappears (a revoked Share, a deleted Task List) is left for whatever +// renders from the source list to filter out, not this function. +export function reconcileCheckedIds( + knownIds: ReadonlySet, + checkedIds: ReadonlySet, + freshIds: Iterable, +): { checkedIds: Set; knownIds: Set } { + const idList = Array.from(freshIds); + const nextChecked = new Set(checkedIds); + for (const id of idList) { + if (!knownIds.has(id)) { + nextChecked.add(id); + } + } + return { checkedIds: nextChecked, knownIds: new Set(idList) }; +} diff --git a/src/lib/shellStore.test.ts b/src/lib/shellStore.test.ts index f32f408..e3b1df0 100644 --- a/src/lib/shellStore.test.ts +++ b/src/lib/shellStore.test.ts @@ -11,6 +11,11 @@ describe("useShellStore", () => { it("defaults activeCalendarSetId to null (All calendars)", () => { expect(useShellStore.getState().activeCalendarSetId).toBeNull(); }); + + // #317, ADR-0083: the Tasks panel is closed by default. + it("defaults tasksPanelOpen to false", () => { + expect(useShellStore.getState().tasksPanelOpen).toBe(false); + }); }); describe("reconcileCheckedCalendarIds", () => { @@ -95,3 +100,81 @@ describe("addCheckedCalendarId", () => { expect(useShellStore.getState().checkedCalendarIds).toEqual(new Set()); }); }); + +// #317, ADR-0083: the Lists filter's checked/known pair carries the +// identical rule as Calendar toggle's, via the same extracted +// reconcileCheckedIds helper — these tests mirror the Calendar ones above. +describe("reconcileCheckedTaskListIds", () => { + it("checks an id it has never seen before", () => { + useShellStore.setState({ + checkedTaskListIds: new Set(), + knownTaskListIds: new Set(), + }); + + useShellStore.getState().reconcileCheckedTaskListIds([1]); + + expect(useShellStore.getState().checkedTaskListIds).toEqual(new Set([1])); + }); + + it("leaves a previously-seen, deliberately-unchecked id unchecked", () => { + useShellStore.setState({ + checkedTaskListIds: new Set(), + knownTaskListIds: new Set([1]), + }); + + useShellStore.getState().reconcileCheckedTaskListIds([1]); + + expect(useShellStore.getState().checkedTaskListIds).toEqual(new Set()); + }); + + it("leaves an already-checked id checked and adds a newly-seen one alongside it", () => { + useShellStore.setState({ + checkedTaskListIds: new Set([1]), + knownTaskListIds: new Set([1]), + }); + + useShellStore.getState().reconcileCheckedTaskListIds([1, 2]); + + expect(useShellStore.getState().checkedTaskListIds).toEqual(new Set([1, 2])); + }); +}); + +describe("toggleTaskListChecked", () => { + it("toggles an id in and out of checkedTaskListIds", () => { + useShellStore.setState({ checkedTaskListIds: new Set() }); + + useShellStore.getState().toggleTaskListChecked(1); + expect(useShellStore.getState().checkedTaskListIds).toEqual(new Set([1])); + + useShellStore.getState().toggleTaskListChecked(1); + expect(useShellStore.getState().checkedTaskListIds).toEqual(new Set()); + }); +}); + +describe("addCheckedTaskListId", () => { + it("checks the id and marks it known, so a later reconcile doesn't undo a deliberate uncheck", () => { + useShellStore.setState({ + checkedTaskListIds: new Set(), + knownTaskListIds: new Set(), + }); + + useShellStore.getState().addCheckedTaskListId(1); + expect(useShellStore.getState().checkedTaskListIds).toEqual(new Set([1])); + expect(useShellStore.getState().knownTaskListIds).toEqual(new Set([1])); + + useShellStore.getState().toggleTaskListChecked(1); + useShellStore.getState().reconcileCheckedTaskListIds([1]); + + expect(useShellStore.getState().checkedTaskListIds).toEqual(new Set()); + }); +}); + +describe("setTasksPanelOpen", () => { + it("sets tasksPanelOpen", () => { + useShellStore.getState().setTasksPanelOpen(true); + expect(useShellStore.getState().tasksPanelOpen).toBe(true); + + useShellStore.getState().setTasksPanelOpen(false); + expect(useShellStore.getState().tasksPanelOpen).toBe(false); + }); +}); diff --git a/src/lib/shellStore.ts b/src/lib/shellStore.ts index b9c0bc0..399131b 100644 --- a/src/lib/shellStore.ts +++ b/src/lib/shellStore.ts @@ -1,5 +1,7 @@ import { create } from "zustand"; import { useCalendarsStore } from "./calendarsStore"; +import { useTaskListsStore } from "./taskListsStore"; +import { reconcileCheckedIds } from "./reconcileCheckedIds"; export type ActiveView = "day" | "week" | "month" | "year"; @@ -22,6 +24,16 @@ interface ShellState { // that disappears (revoked) drops out of it, so a later re-Share is // "unseen" again and gets auto-checked rather than staying invisible. knownCalendarIds: Set; + // tasksPanelOpen is the Tasks panel's own open/closed state (#317, + // ADR-0083): closed by default, session state like activeCalendarSetId + // with no Preference behind it and nothing persisted between loads. + tasksPanelOpen: boolean; + // checkedTaskListIds/knownTaskListIds are the Lists filter's own + // checked/known pair — the Task List counterpart to + // checkedCalendarIds/knownCalendarIds, carrying the identical reconcile + // rule via reconcileCheckedIds (#317, ADR-0083). + checkedTaskListIds: Set; + knownTaskListIds: Set; // requestedEventId is set by a click on an invite Notification (the // NotificationBell has no reach into AppShell's own eventModalState) and // cleared once AppShell has resolved it into an opened EventModal — a @@ -32,6 +44,7 @@ interface ShellState { setSelectedDate: (date: Date) => void; setActiveView: (view: ActiveView) => void; setActiveCalendarSetId: (id: number | null) => void; + setTasksPanelOpen: (open: boolean) => void; setCheckedCalendarIds: (ids: Iterable) => void; // reconcileCheckedCalendarIds auto-checks only ids not previously known — // e.g. a Calendar shared with the caller while the tab was in the @@ -48,6 +61,12 @@ interface ShellState { // doesn't treat it as unseen and re-check it after a deliberate uncheck // (#175). addCheckedCalendarId: (id: string) => void; + // reconcileCheckedTaskListIds/toggleTaskListChecked/addCheckedTaskListId + // are checkedCalendarIds' three counterparts above, over Task List ids + // (#317, ADR-0083). + reconcileCheckedTaskListIds: (ids: Iterable) => void; + toggleTaskListChecked: (id: number) => void; + addCheckedTaskListId: (id: number) => void; } export const useShellStore = create((set) => ({ @@ -60,23 +79,33 @@ export const useShellStore = create((set) => ({ knownCalendarIds: new Set( useCalendarsStore.getState().calendars.map((calendar) => calendar.id), ), + tasksPanelOpen: false, + // Unlike checkedCalendarIds/knownCalendarIds above, not seeded from the + // store at module-init time: taskListsStore sits downstream of authStore, + // which itself imports shellStore, so reading it synchronously here would + // close a circular import the wrong way round depending on which module + // loads first. Starting empty costs nothing — taskListsStore itself + // starts empty until refetchTaskListsAndReconcile below runs — and the + // import stays safe because it's only ever touched lazily, inside that + // function's body. + checkedTaskListIds: new Set(), + knownTaskListIds: new Set(), requestedEventId: null, requestEventOpen: (eventId) => set({ requestedEventId: eventId }), clearRequestedEventOpen: () => set({ requestedEventId: null }), setSelectedDate: (date) => set({ selectedDate: date }), setActiveView: (view) => set({ activeView: view }), setActiveCalendarSetId: (id) => set({ activeCalendarSetId: id }), + setTasksPanelOpen: (open) => set({ tasksPanelOpen: open }), setCheckedCalendarIds: (ids) => set({ checkedCalendarIds: new Set(ids) }), reconcileCheckedCalendarIds: (ids) => set((state) => { - const idList = Array.from(ids); - const nextChecked = new Set(state.checkedCalendarIds); - for (const id of idList) { - if (!state.knownCalendarIds.has(id)) { - nextChecked.add(id); - } - } - return { checkedCalendarIds: nextChecked, knownCalendarIds: new Set(idList) }; + const { checkedIds, knownIds } = reconcileCheckedIds( + state.knownCalendarIds, + state.checkedCalendarIds, + ids, + ); + return { checkedCalendarIds: checkedIds, knownCalendarIds: knownIds }; }), toggleCalendarChecked: (id) => set((state) => { @@ -99,6 +128,30 @@ export const useShellStore = create((set) => ({ checkedCalendarIds: new Set(state.checkedCalendarIds).add(id), knownCalendarIds: new Set(state.knownCalendarIds).add(id), })), + reconcileCheckedTaskListIds: (ids) => + set((state) => { + const { checkedIds, knownIds } = reconcileCheckedIds( + state.knownTaskListIds, + state.checkedTaskListIds, + ids, + ); + return { checkedTaskListIds: checkedIds, knownTaskListIds: knownIds }; + }), + toggleTaskListChecked: (id) => + set((state) => { + const next = new Set(state.checkedTaskListIds); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return { checkedTaskListIds: next }; + }), + addCheckedTaskListId: (id) => + set((state) => ({ + checkedTaskListIds: new Set(state.checkedTaskListIds).add(id), + knownTaskListIds: new Set(state.knownTaskListIds).add(id), + })), })); // Shared by every caller that refetches Calendars and needs the checked set @@ -114,3 +167,14 @@ export async function refetchCalendarsAndReconcile(): Promise { useCalendarsStore.getState().calendars.map((calendar) => calendar.id), ); } + +// refetchTaskListsAndReconcile is refetchCalendarsAndReconcile's counterpart +// for Task Lists (#317, ADR-0083). +export async function refetchTaskListsAndReconcile(): Promise { + await useTaskListsStore.getState().fetchTaskLists(); + useShellStore + .getState() + .reconcileCheckedTaskListIds( + useTaskListsStore.getState().taskLists.map((taskList) => taskList.id), + ); +} diff --git a/src/lib/taskListsApi.ts b/src/lib/taskListsApi.ts new file mode 100644 index 0000000..a7b6716 --- /dev/null +++ b/src/lib/taskListsApi.ts @@ -0,0 +1,91 @@ +import { authedFetch, errorFromResponse } from "./apiClient"; +import { workspaceHeaders } from "./workspaceHeaders"; + +// Task Lists (#317, ADR-0083): a named, private container of Tasks +// belonging to one User within one Workspace. Scoped to the currently +// active Workspace via workspaceHeaders, the same as every other +// Workspace-scoped call. + +export interface TaskList { + id: number; + name: string; + color: string; + isDefault: boolean; +} + +export const taskListsApi = { + // Every Task List the caller owns in the active Workspace. + async list(accessToken: string): Promise { + const response = await authedFetch(accessToken, "/api/task-lists/", { + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as TaskList[]; + }, + + // Creates a new, non-default Task List named name in the active + // Workspace. An empty color auto-assigns a Swatch. + async create(accessToken: string, name: string, color = ""): Promise { + const response = await authedFetch(accessToken, "/api/task-lists/", { + method: "POST", + credentials: "include", + headers: workspaceHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ name, color }), + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as TaskList; + }, + + // Renames id. + async rename(accessToken: string, id: number, name: string): Promise { + const response = await authedFetch(accessToken, `/api/task-lists/${id}`, { + method: "PATCH", + credentials: "include", + headers: workspaceHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ name }), + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as TaskList; + }, + + // Recolors id. + async recolor(accessToken: string, id: number, color: string): Promise { + const response = await authedFetch(accessToken, `/api/task-lists/${id}/color`, { + method: "PATCH", + credentials: "include", + headers: workspaceHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ color }), + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as TaskList; + }, + + // Promotes id to be the caller's default Task List, atomically clearing + // whichever one held the flag before. + async setDefault(accessToken: string, id: number): Promise { + const response = await authedFetch(accessToken, `/api/task-lists/${id}/default`, { + method: "PUT", + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as TaskList; + }, + + // Deletes id outright. Refused by the backend while id still holds the + // default flag. + async remove(accessToken: string, id: number): Promise { + const response = await authedFetch(accessToken, `/api/task-lists/${id}`, { + method: "DELETE", + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + }, +}; diff --git a/src/lib/taskListsStore.test.ts b/src/lib/taskListsStore.test.ts new file mode 100644 index 0000000..03b315a --- /dev/null +++ b/src/lib/taskListsStore.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./taskListsApi", () => ({ + taskListsApi: { + list: vi.fn(), + create: vi.fn(), + rename: vi.fn(), + recolor: vi.fn(), + setDefault: vi.fn(), + remove: vi.fn(), + }, +})); + +const { taskListsApi } = await import("./taskListsApi"); +const { useTaskListsStore } = await import("./taskListsStore"); +const { useAuthStore } = await import("./authStore"); + +const inbox = { id: 1, name: "Inbox", color: "#E2483DFF", isDefault: true }; +const work = { id: 2, name: "Work", color: "#12809CFF", isDefault: false }; + +beforeEach(() => { + vi.clearAllMocks(); + useAuthStore.setState({ accessToken: "token-123" }); + useTaskListsStore.setState({ taskLists: [inbox, work] }); +}); + +// ADR-0083: promoting a Task List to default is atomic on the backend +// (clear old, set new in one transaction) — the local state update mirrors +// that by clearing isDefault on every other list in the same set() call. +describe("setDefaultTaskList", () => { + it("promotes the target and clears the flag on whichever list held it before", async () => { + vi.mocked(taskListsApi.setDefault).mockResolvedValue({ ...work, isDefault: true }); + + await useTaskListsStore.getState().setDefaultTaskList(work.id); + + const lists = useTaskListsStore.getState().taskLists; + expect(lists.find((l) => l.id === work.id)?.isDefault).toBe(true); + expect(lists.find((l) => l.id === inbox.id)?.isDefault).toBe(false); + }); +}); + +describe("deleteTaskList", () => { + it("removes the deleted list from local state", async () => { + vi.mocked(taskListsApi.remove).mockResolvedValue(undefined); + + await useTaskListsStore.getState().deleteTaskList(work.id); + + expect(useTaskListsStore.getState().taskLists.map((l) => l.id)).toEqual([inbox.id]); + }); +}); diff --git a/src/lib/taskListsStore.ts b/src/lib/taskListsStore.ts new file mode 100644 index 0000000..b6580b8 --- /dev/null +++ b/src/lib/taskListsStore.ts @@ -0,0 +1,68 @@ +import { create } from "zustand"; +import { useAuthStore } from "./authStore"; +import { type TaskList, taskListsApi } from "./taskListsApi"; + +// The Tasks panel's Lists filter data source (#317, ADR-0083): every Task +// List the caller owns in the active Workspace. Mirrors calendarSetsStore's +// shape. Deliberately no cross-store reach into shellStore here (unlike +// calendarSetsStore.deleteCalendarSet) — createTaskListCascade.ts is where +// "checked the moment it's created" is composed, the same split +// createCalendarCascade.ts already keeps between calendarsStore and +// shellStore. +interface TaskListsState { + taskLists: TaskList[]; + fetchTaskLists: () => Promise; + createTaskList: (name: string, color?: string) => Promise; + renameTaskList: (id: number, name: string) => Promise; + recolorTaskList: (id: number, color: string) => Promise; + setDefaultTaskList: (id: number) => Promise; + deleteTaskList: (id: number) => Promise; +} + +function requireAccessToken(): string { + const accessToken = useAuthStore.getState().accessToken; + if (!accessToken) throw new Error("Not authenticated."); + return accessToken; +} + +export const useTaskListsStore = create((set, get) => ({ + taskLists: [], + + fetchTaskLists: async () => { + const taskLists = await taskListsApi.list(requireAccessToken()); + set({ taskLists }); + }, + + createTaskList: async (name, color) => { + const created = await taskListsApi.create(requireAccessToken(), name, color); + set({ taskLists: [...get().taskLists, created] }); + return created; + }, + + renameTaskList: async (id, name) => { + const renamed = await taskListsApi.rename(requireAccessToken(), id, name); + set({ taskLists: get().taskLists.map((l) => (l.id === id ? renamed : l)) }); + }, + + recolorTaskList: async (id, color) => { + const recolored = await taskListsApi.recolor(requireAccessToken(), id, color); + set({ taskLists: get().taskLists.map((l) => (l.id === id ? recolored : l)) }); + }, + + // Promoting id to default clears the flag locally on whichever list held + // it before, mirroring the atomic clear-then-set the backend performs in + // one transaction (ADR-0083). + setDefaultTaskList: async (id) => { + const promoted = await taskListsApi.setDefault(requireAccessToken(), id); + set({ + taskLists: get().taskLists.map((l) => + l.id === promoted.id ? promoted : l.isDefault ? { ...l, isDefault: false } : l, + ), + }); + }, + + deleteTaskList: async (id) => { + await taskListsApi.remove(requireAccessToken(), id); + set({ taskLists: get().taskLists.filter((l) => l.id !== id) }); + }, +})); From afb707aa283fd6bfef8962c241f21c76268286b3 Mon Sep 17 00:00:00 2001 From: XiovV Date: Fri, 11 Sep 2026 15:42:03 +0200 Subject: [PATCH 16/33] Capture and tick off a Task (#310) Quick-add commits a Task on Enter with no dialog, targeting the default Task List or the single checked one in the Lists filter. Every row gets a one-click completion control, written optimistically with rollback on failure; completed Tasks stay hidden behind "Show completed" and are fetched only when asked for, bounded to a recent tail. Deleting a Task List now reparents its Tasks to the default in one transaction instead of destroying them. Fixes #310 --- server/cmd/server/main.go | 2 +- server/internal/app/app.go | 2 + server/internal/db/migrations/00011_tasks.sql | 44 ++ server/internal/handlers/task.go | 189 ++++++++ server/internal/handlers/task_test.go | 443 ++++++++++++++++++ server/internal/repository/task.go | 196 ++++++++ server/internal/repository/task_list.go | 19 + server/internal/router/router.go | 22 +- server/internal/router/router_test.go | 2 +- server/internal/service/graph.go | 6 +- server/internal/service/task.go | 116 +++++ server/internal/service/task_list.go | 25 +- .../layout/TaskCompletionControl.tsx | 32 ++ src/components/layout/TaskQuickAdd.tsx | 44 ++ src/components/layout/TaskRow.tsx | 34 ++ src/components/layout/TasksPanel.tsx | 68 ++- src/lib/shellStore.ts | 7 + src/lib/tasksApi.ts | 103 ++++ src/lib/tasksStore.test.ts | 153 ++++++ src/lib/tasksStore.ts | 136 ++++++ 20 files changed, 1627 insertions(+), 16 deletions(-) create mode 100644 server/internal/db/migrations/00011_tasks.sql create mode 100644 server/internal/handlers/task.go create mode 100644 server/internal/handlers/task_test.go create mode 100644 server/internal/repository/task.go create mode 100644 server/internal/service/task.go create mode 100644 src/components/layout/TaskCompletionControl.tsx create mode 100644 src/components/layout/TaskQuickAdd.tsx create mode 100644 src/components/layout/TaskRow.tsx create mode 100644 src/lib/tasksApi.ts create mode 100644 src/lib/tasksStore.test.ts create mode 100644 src/lib/tasksStore.ts diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 9ff555c..7013840 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -87,7 +87,7 @@ func main() { } } - handler, err := router.New(logger, a.AuthHandler, a.CalendarHandler, a.EventHandler, a.AttachmentHandler, a.NotificationHandler, a.AppPasswordHandler, a.AccountHandler, a.UserHandler, a.WorkspaceHandler, a.GroupHandler, a.CalendarSetHandler, a.TaskListHandler, a.ConnectionHandler, a.CalDAVHandler, a.Auth, a.Auth, a.AppPasswords, a.RateLimiter, a.Auth, a.Workspaces) + handler, err := router.New(logger, a.AuthHandler, a.CalendarHandler, a.EventHandler, a.AttachmentHandler, a.NotificationHandler, a.AppPasswordHandler, a.AccountHandler, a.UserHandler, a.WorkspaceHandler, a.GroupHandler, a.CalendarSetHandler, a.TaskListHandler, a.TaskHandler, a.ConnectionHandler, a.CalDAVHandler, a.Auth, a.Auth, a.AppPasswords, a.RateLimiter, a.Auth, a.Workspaces) if err != nil { logger.Error("failed to build router", "error", err) os.Exit(1) diff --git a/server/internal/app/app.go b/server/internal/app/app.go index 3fdf2ec..229b403 100644 --- a/server/internal/app/app.go +++ b/server/internal/app/app.go @@ -58,6 +58,7 @@ type App struct { GroupHandler *handlers.GroupHandler CalendarSetHandler *handlers.CalendarSetHandler TaskListHandler *handlers.TaskListHandler + TaskHandler *handlers.TaskHandler ConnectionHandler *handlers.ConnectionHandler CalDAVBackend *caldavserver.Backend @@ -122,6 +123,7 @@ func newFromGraph(graph *service.Graph, cfg config.Config) *App { a.GroupHandler = handlers.NewGroupHandler(a.Groups) a.CalendarSetHandler = handlers.NewCalendarSetHandler(a.CalendarSets) a.TaskListHandler = handlers.NewTaskListHandler(a.TaskLists) + a.TaskHandler = handlers.NewTaskHandler(a.Tasks) a.ConnectionHandler = handlers.NewConnectionHandler(a.Connections) a.CalDAVBackend = caldavserver.NewBackend(a.Calendars, a.Events, a.Attachments, cfg.MaxAttachmentSize, cfg.MaxAttachmentsPerEvent) diff --git a/server/internal/db/migrations/00011_tasks.sql b/server/internal/db/migrations/00011_tasks.sql new file mode 100644 index 0000000..1c380b4 --- /dev/null +++ b/server/internal/db/migrations/00011_tasks.sql @@ -0,0 +1,44 @@ +-- +goose Up +-- tasks is a Task (#310, ADR-0083, CONTEXT.md's Task entry): a unit of work +-- a User tracks, belonging to exactly one Task List and private to its +-- owner. Modelled on VTODO, scoped (user_id, workspace_id) like task_lists +-- (00010_task_lists.sql), and cascading on users, workspaces and +-- task_lists — a Task never outlives the User, Workspace or Task List it +-- belongs to. +-- +-- Columns are named for what VTODO holds: title (SUMMARY), notes +-- (DESCRIPTION), due (DUE, the Deadline), start + duration_minutes (DTSTART +-- + DURATION, the Time block — two independent axes per ADR-0083, so +-- neither is derived from the other), priority (PRIORITY, 0-9), completed_at +-- (COMPLETED, nullable, never a status enum). This ticket only exercises +-- title and completed_at; due, start, duration_minutes, priority and notes +-- land now, unused, so a later ticket is a codec-and-backend job rather than +-- a migration (ADR-0083). +-- +-- Deleting a Task List reparents its Tasks to the default Task List, in the +-- same transaction, before the Task List row itself is removed +-- (TaskListService.Delete) — so task_list_id's ON DELETE CASCADE below is a +-- safety net for the case that never happens (the default Task List cannot +-- itself be deleted while it holds the flag), not the mechanism reparenting +-- relies on. +CREATE TABLE tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + task_list_id INTEGER NOT NULL REFERENCES task_lists(id) ON DELETE CASCADE, + title TEXT NOT NULL, + notes TEXT NOT NULL DEFAULT '', + due TIMESTAMP, + start TIMESTAMP, + duration_minutes INTEGER, + priority INTEGER NOT NULL DEFAULT 0, + completed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_tasks_user_id ON tasks(user_id); +CREATE INDEX idx_tasks_workspace_id ON tasks(workspace_id); +CREATE INDEX idx_tasks_task_list_id ON tasks(task_list_id); + +-- +goose Down +DROP TABLE tasks; diff --git a/server/internal/handlers/task.go b/server/internal/handlers/task.go new file mode 100644 index 0000000..812637a --- /dev/null +++ b/server/internal/handlers/task.go @@ -0,0 +1,189 @@ +// task.go implements TaskHandler: List/ListCompleted/Create/Update/Delete/ +// Complete/Uncomplete back the Tasks panel's quick-add and completion +// control (#310, ADR-0083). A Task is private outright, so every route +// resolves by the caller's own userID and the active workspaceID together +// and renders a mismatch as 404 — a Task belonging to someone else is +// indistinguishable from one that does not exist. +package handlers + +import ( + "net/http" + + "github.com/XiovV/calich/server/internal/httpauth" + "github.com/XiovV/calich/server/internal/httpresponse" + "github.com/XiovV/calich/server/internal/repository" + "github.com/XiovV/calich/server/internal/service" +) + +type TaskHandler struct { + tasks *service.TaskService +} + +func NewTaskHandler(tasks *service.TaskService) *TaskHandler { + return &TaskHandler{tasks: tasks} +} + +type taskResponse struct { + ID int64 `json:"id"` + TaskListID int64 `json:"taskListId"` + Title string `json:"title"` + Completed bool `json:"completed"` +} + +func toTaskResponse(t repository.Task) taskResponse { + return taskResponse{ + ID: t.ID, + TaskListID: t.TaskListID, + Title: t.Title, + Completed: t.CompletedAt != nil, + } +} + +// taskErrors renders the sentinels every TaskService call can return: an +// empty title, and repository.ErrNotFound for a Task (or, on Create, a Task +// List) that doesn't exist, belongs to another User, or belongs to another +// Workspace — indistinguishable by design. +var taskErrors = []errorCase{ + {service.ErrInvalidTaskTitle, badRequest(service.ErrInvalidTaskTitle.Error())}, + {repository.ErrNotFound, notFound("task not found")}, +} + +// List serves GET /api/tasks: every incomplete Task the caller owns in +// their active Workspace, unwindowed by date. +func (h *TaskHandler) List(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + + tasks, err := h.tasks.ListIncomplete(r.Context(), userID, workspaceID) + if respondError(w, err, taskErrors, "failed to list tasks") { + return + } + + httpresponse.JSON(w, http.StatusOK, toTaskResponses(tasks)) +} + +// ListCompleted serves GET /api/tasks/completed: the caller's most recently +// completed Tasks, bounded to a recent tail rather than all history. +func (h *TaskHandler) ListCompleted(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + + tasks, err := h.tasks.ListCompleted(r.Context(), userID, workspaceID) + if respondError(w, err, taskErrors, "failed to list completed tasks") { + return + } + + httpresponse.JSON(w, http.StatusOK, toTaskResponses(tasks)) +} + +func toTaskResponses(tasks []repository.Task) []taskResponse { + response := make([]taskResponse, len(tasks)) + for i, t := range tasks { + response[i] = toTaskResponse(t) + } + return response +} + +type createTaskRequest struct { + Title string `json:"title"` + TaskListID int64 `json:"taskListId"` +} + +// Create makes a new, incomplete Task in the caller's active Workspace — +// the quick-add field's commit-on-Enter. TaskListID is resolved client-side +// (the default Task List, or the single checked Task List in the Lists +// filter) before this is ever called. +func (h *TaskHandler) Create(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + + req, ok := decodeJSON[createTaskRequest](w, r) + if !ok { + return + } + + task, err := h.tasks.Create(r.Context(), userID, workspaceID, req.TaskListID, req.Title) + if respondError(w, err, taskErrors, "failed to create task") { + return + } + + httpresponse.JSON(w, http.StatusCreated, toTaskResponse(task)) +} + +type updateTaskRequest struct { + Title string `json:"title"` +} + +// Update changes a Task's title. +func (h *TaskHandler) Update(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + id, ok := parseInt64Param(w, r, "id") + if !ok { + return + } + + req, ok := decodeJSON[updateTaskRequest](w, r) + if !ok { + return + } + + task, err := h.tasks.Update(r.Context(), userID, workspaceID, id, req.Title) + if respondError(w, err, taskErrors, "failed to update task") { + return + } + + httpresponse.JSON(w, http.StatusOK, toTaskResponse(task)) +} + +// Complete serves PUT /api/tasks/{id}/complete: the completion control's +// one-click, no-confirmation commit (ADR-0068). +func (h *TaskHandler) Complete(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + id, ok := parseInt64Param(w, r, "id") + if !ok { + return + } + + task, err := h.tasks.Complete(r.Context(), userID, workspaceID, id) + if respondError(w, err, taskErrors, "failed to complete task") { + return + } + + httpresponse.JSON(w, http.StatusOK, toTaskResponse(task)) +} + +// Uncomplete serves DELETE /api/tasks/{id}/complete: the completion +// control's second click, un-completing a Task. +func (h *TaskHandler) Uncomplete(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + id, ok := parseInt64Param(w, r, "id") + if !ok { + return + } + + task, err := h.tasks.Uncomplete(r.Context(), userID, workspaceID, id) + if respondError(w, err, taskErrors, "failed to uncomplete task") { + return + } + + httpresponse.JSON(w, http.StatusOK, toTaskResponse(task)) +} + +// Delete removes a Task outright. +func (h *TaskHandler) Delete(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + id, ok := parseInt64Param(w, r, "id") + if !ok { + return + } + + if err := h.tasks.Delete(r.Context(), userID, workspaceID, id); respondError(w, err, taskErrors, "failed to delete task") { + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/internal/handlers/task_test.go b/server/internal/handlers/task_test.go new file mode 100644 index 0000000..41525c5 --- /dev/null +++ b/server/internal/handlers/task_test.go @@ -0,0 +1,443 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/XiovV/calich/server/internal/apptest" + "github.com/XiovV/calich/server/internal/httpauth" + "github.com/XiovV/calich/server/internal/service" +) + +// taskHandlerTestServer bundles the HTTP surface Tasks needs (#310, +// ADR-0083): Register (to mint real Users, each with their own Workspace +// and auto-provisioned default Inbox Task List) plus the /api/task-lists +// and /api/tasks routes, gated exactly like router.New wires them. +type taskHandlerTestServer struct { + srv *httptest.Server + graph *service.Graph +} + +func newTaskHandlerTestServer(t *testing.T) *taskHandlerTestServer { + t.Helper() + + cfg := apptest.Config(t) + cfg.InitialName, cfg.InitialEmail, cfg.InitialPassword = "", "", "" + cfg.EnableSignups = true + g := newTestGraphWithConfig(t, cfg) + + auth := g.Auth + taskLists := g.TaskLists + tasks := g.Tasks + + authHandler := NewAuthHandler(auth, g.RateLimiter, false, false, false, true) + taskListHandler := NewTaskListHandler(taskLists) + taskHandler := NewTaskHandler(tasks) + + r := chi.NewRouter() + r.Post("/api/auth/register", authHandler.Register) + + r.Route("/api/task-lists", func(r chi.Router) { + r.Use(httpauth.RequireAuth(auth)) + r.Use(httpauth.RequireEnabledUser(auth)) + r.Use(httpauth.RequireWorkspace(g.Workspaces)) + + r.Get("/", taskListHandler.List) + r.Post("/", taskListHandler.Create) + r.Patch("/{id}", taskListHandler.Rename) + r.Patch("/{id}/color", taskListHandler.Recolor) + r.Put("/{id}/default", taskListHandler.SetDefault) + r.Delete("/{id}", taskListHandler.Delete) + }) + + r.Route("/api/tasks", func(r chi.Router) { + r.Use(httpauth.RequireAuth(auth)) + r.Use(httpauth.RequireEnabledUser(auth)) + r.Use(httpauth.RequireWorkspace(g.Workspaces)) + + r.Get("/", taskHandler.List) + r.Get("/completed", taskHandler.ListCompleted) + r.Post("/", taskHandler.Create) + r.Patch("/{id}", taskHandler.Update) + r.Put("/{id}/complete", taskHandler.Complete) + r.Delete("/{id}/complete", taskHandler.Uncomplete) + r.Delete("/{id}", taskHandler.Delete) + }) + + srv := httptest.NewServer(r) + t.Cleanup(srv.Close) + + return &taskHandlerTestServer{srv: srv, graph: g} +} + +func (s *taskHandlerTestServer) register(t *testing.T, username string) (accessToken string, userID, workspaceID int64) { + t.Helper() + ctx := context.Background() + + body, err := json.Marshal(registerRequest{Name: username, Email: username + "@example.com", Password: "hunter22"}) + if err != nil { + t.Fatalf("marshal register request: %v", err) + } + resp, err := http.Post(s.srv.URL+"/api/auth/register", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("POST /api/auth/register: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 registering %s, got %d", username, resp.StatusCode) + } + var logged loginResponse + if err := json.NewDecoder(resp.Body).Decode(&logged); err != nil { + t.Fatalf("decode register response: %v", err) + } + + users := s.graph.UserRepo + user, err := users.GetByEmail(ctx, username+"@example.com") + if err != nil { + t.Fatalf("get %s: %v", username, err) + } + workspaces, err := s.graph.Workspaces.ListForUser(ctx, user.ID) + if err != nil || len(workspaces) != 1 { + t.Fatalf("list workspaces for %s: %v (%d)", username, err, len(workspaces)) + } + + return logged.AccessToken, user.ID, workspaces[0].ID +} + +func (s *taskHandlerTestServer) do(t *testing.T, method, path, accessToken string, workspaceID int64, body any) *http.Response { + t.Helper() + + var reader *bytes.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + reader = bytes.NewReader(b) + } else { + reader = bytes.NewReader(nil) + } + + req, err := http.NewRequest(method, s.srv.URL+path, reader) + if err != nil { + t.Fatalf("build request: %v", err) + } + if accessToken != "" { + req.Header.Set("Authorization", "Bearer "+accessToken) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Workspace-Id", strconv.FormatInt(workspaceID, 10)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + return resp +} + +// defaultTaskListID returns the caller's auto-provisioned default (Inbox) +// Task List id. +func (s *taskHandlerTestServer) defaultTaskListID(t *testing.T, accessToken string, workspaceID int64) int64 { + t.Helper() + + resp := s.do(t, http.MethodGet, "/api/task-lists/", accessToken, workspaceID, nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 listing task lists, got %d", resp.StatusCode) + } + var lists []taskListResponse + if err := json.NewDecoder(resp.Body).Decode(&lists); err != nil { + t.Fatalf("decode list task lists response: %v", err) + } + for _, l := range lists { + if l.IsDefault { + return l.ID + } + } + t.Fatalf("expected a default task list, got %v", lists) + return 0 +} + +func (s *taskHandlerTestServer) createTaskList(t *testing.T, accessToken string, workspaceID int64, name, color string) taskListResponse { + t.Helper() + + resp := s.do(t, http.MethodPost, "/api/task-lists/", accessToken, workspaceID, createTaskListRequest{Name: name, Color: color}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 creating task list, got %d", resp.StatusCode) + } + var list taskListResponse + if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { + t.Fatalf("decode create task list response: %v", err) + } + return list +} + +func (s *taskHandlerTestServer) createTask(t *testing.T, accessToken string, workspaceID, taskListID int64, title string) taskResponse { + t.Helper() + + resp := s.do(t, http.MethodPost, "/api/tasks/", accessToken, workspaceID, createTaskRequest{Title: title, TaskListID: taskListID}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 creating task, got %d", resp.StatusCode) + } + var task taskResponse + if err := json.NewDecoder(resp.Body).Decode(&task); err != nil { + t.Fatalf("decode create task response: %v", err) + } + return task +} + +func (s *taskHandlerTestServer) listTasks(t *testing.T, accessToken string, workspaceID int64) []taskResponse { + t.Helper() + + resp := s.do(t, http.MethodGet, "/api/tasks/", accessToken, workspaceID, nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 listing tasks, got %d", resp.StatusCode) + } + var tasks []taskResponse + if err := json.NewDecoder(resp.Body).Decode(&tasks); err != nil { + t.Fatalf("decode list tasks response: %v", err) + } + return tasks +} + +func (s *taskHandlerTestServer) listCompletedTasks(t *testing.T, accessToken string, workspaceID int64) []taskResponse { + t.Helper() + + resp := s.do(t, http.MethodGet, "/api/tasks/completed", accessToken, workspaceID, nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 listing completed tasks, got %d", resp.StatusCode) + } + var tasks []taskResponse + if err := json.NewDecoder(resp.Body).Decode(&tasks); err != nil { + t.Fatalf("decode list completed tasks response: %v", err) + } + return tasks +} + +func TestTaskHandler_Create_QuickAdd(t *testing.T) { + s := newTaskHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + inboxID := s.defaultTaskListID(t, token, workspaceID) + + task := s.createTask(t, token, workspaceID, inboxID, "Buy milk") + if task.Title != "Buy milk" { + t.Fatalf("expected title %q, got %q", "Buy milk", task.Title) + } + if task.TaskListID != inboxID { + t.Fatalf("expected task list id %d, got %d", inboxID, task.TaskListID) + } + if task.Completed { + t.Fatalf("expected a freshly created task to be incomplete") + } +} + +func TestTaskHandler_Create_EmptyTitleRejected(t *testing.T) { + s := newTaskHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + inboxID := s.defaultTaskListID(t, token, workspaceID) + + resp := s.do(t, http.MethodPost, "/api/tasks/", token, workspaceID, createTaskRequest{Title: " ", TaskListID: inboxID}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestTaskHandler_Create_RejectsAnotherUsersTaskList(t *testing.T) { + s := newTaskHandlerTestServer(t) + aliceToken, _, aliceWorkspaceID := s.register(t, "alice") + bobToken, _, bobWorkspaceID := s.register(t, "bob") + bobInboxID := s.defaultTaskListID(t, bobToken, bobWorkspaceID) + _ = aliceWorkspaceID + + resp := s.do(t, http.MethodPost, "/api/tasks/", aliceToken, aliceWorkspaceID, createTaskRequest{Title: "Hijack", TaskListID: bobInboxID}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 filing a task into another user's task list, got %d", resp.StatusCode) + } +} + +func TestTaskHandler_List_UnwindowedIncludesUndated(t *testing.T) { + s := newTaskHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + inboxID := s.defaultTaskListID(t, token, workspaceID) + + s.createTask(t, token, workspaceID, inboxID, "Buy milk") + s.createTask(t, token, workspaceID, inboxID, "Walk the dog") + + tasks := s.listTasks(t, token, workspaceID) + if len(tasks) != 2 { + t.Fatalf("expected both undated tasks to appear in the unwindowed list, got %v", tasks) + } +} + +func TestTaskHandler_List_ExcludesCompletedUnlessAsked(t *testing.T) { + s := newTaskHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + inboxID := s.defaultTaskListID(t, token, workspaceID) + + task := s.createTask(t, token, workspaceID, inboxID, "Buy milk") + + completeResp := s.do(t, http.MethodPut, "/api/tasks/"+strconv.FormatInt(task.ID, 10)+"/complete", token, workspaceID, nil) + defer completeResp.Body.Close() + if completeResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 completing, got %d", completeResp.StatusCode) + } + + tasks := s.listTasks(t, token, workspaceID) + if len(tasks) != 0 { + t.Fatalf("expected the completed task to be excluded from the default list, got %v", tasks) + } + + completed := s.listCompletedTasks(t, token, workspaceID) + if len(completed) != 1 || !completed[0].Completed { + t.Fatalf("expected the completed task to appear when asked for, got %v", completed) + } +} + +func TestTaskHandler_CompleteAndUncomplete(t *testing.T) { + s := newTaskHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + inboxID := s.defaultTaskListID(t, token, workspaceID) + task := s.createTask(t, token, workspaceID, inboxID, "Buy milk") + + completeResp := s.do(t, http.MethodPut, "/api/tasks/"+strconv.FormatInt(task.ID, 10)+"/complete", token, workspaceID, nil) + defer completeResp.Body.Close() + if completeResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 completing, got %d", completeResp.StatusCode) + } + var completed taskResponse + if err := json.NewDecoder(completeResp.Body).Decode(&completed); err != nil { + t.Fatalf("decode complete response: %v", err) + } + if !completed.Completed { + t.Fatalf("expected the task to be marked completed") + } + + uncompleteResp := s.do(t, http.MethodDelete, "/api/tasks/"+strconv.FormatInt(task.ID, 10)+"/complete", token, workspaceID, nil) + defer uncompleteResp.Body.Close() + if uncompleteResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 uncompleting, got %d", uncompleteResp.StatusCode) + } + var uncompleted taskResponse + if err := json.NewDecoder(uncompleteResp.Body).Decode(&uncompleted); err != nil { + t.Fatalf("decode uncomplete response: %v", err) + } + if uncompleted.Completed { + t.Fatalf("expected the task to be marked incomplete again") + } +} + +func TestTaskHandler_Update(t *testing.T) { + s := newTaskHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + inboxID := s.defaultTaskListID(t, token, workspaceID) + task := s.createTask(t, token, workspaceID, inboxID, "Buy milk") + + resp := s.do(t, http.MethodPatch, "/api/tasks/"+strconv.FormatInt(task.ID, 10), token, workspaceID, updateTaskRequest{Title: "Buy oat milk"}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 updating, got %d", resp.StatusCode) + } + var updated taskResponse + if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil { + t.Fatalf("decode update response: %v", err) + } + if updated.Title != "Buy oat milk" { + t.Fatalf("expected title %q, got %q", "Buy oat milk", updated.Title) + } +} + +func TestTaskHandler_Delete(t *testing.T) { + s := newTaskHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + inboxID := s.defaultTaskListID(t, token, workspaceID) + task := s.createTask(t, token, workspaceID, inboxID, "Buy milk") + + resp := s.do(t, http.MethodDelete, "/api/tasks/"+strconv.FormatInt(task.ID, 10), token, workspaceID, nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204 deleting, got %d", resp.StatusCode) + } + + tasks := s.listTasks(t, token, workspaceID) + if len(tasks) != 0 { + t.Fatalf("expected the task to be gone, got %v", tasks) + } +} + +// TestTaskHandler_SecondUserCannotReadUpdateCompleteOrDeleteFirstsTask +// proves the privacy boundary between two Users holds for Tasks exactly as +// it does for Task Lists (#310, ADR-0083): bob is even added as a Member of +// alice's Workspace, and it still doesn't matter. +func TestTaskHandler_SecondUserCannotReadUpdateCompleteOrDeleteFirstsTask(t *testing.T) { + s := newTaskHandlerTestServer(t) + aliceToken, _, workspaceID := s.register(t, "alice") + bobToken, bobID, _ := s.register(t, "bob") + if _, err := s.graph.DB.Exec("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", workspaceID, bobID, "member"); err != nil { + t.Fatalf("add bob to alice's workspace: %v", err) + } + + inboxID := s.defaultTaskListID(t, aliceToken, workspaceID) + task := s.createTask(t, aliceToken, workspaceID, inboxID, "Buy milk") + + updateResp := s.do(t, http.MethodPatch, "/api/tasks/"+strconv.FormatInt(task.ID, 10), bobToken, workspaceID, updateTaskRequest{Title: "Hijacked"}) + defer updateResp.Body.Close() + if updateResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 updating, got %d", updateResp.StatusCode) + } + + completeResp := s.do(t, http.MethodPut, "/api/tasks/"+strconv.FormatInt(task.ID, 10)+"/complete", bobToken, workspaceID, nil) + defer completeResp.Body.Close() + if completeResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 completing, got %d", completeResp.StatusCode) + } + + deleteResp := s.do(t, http.MethodDelete, "/api/tasks/"+strconv.FormatInt(task.ID, 10), bobToken, workspaceID, nil) + defer deleteResp.Body.Close() + if deleteResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 deleting, got %d", deleteResp.StatusCode) + } + + survivors := s.listTasks(t, aliceToken, workspaceID) + if len(survivors) != 1 || survivors[0].Title != "Buy milk" { + t.Fatalf("expected alice's task to survive untouched, got %v", survivors) + } +} + +// TestTaskHandler_DeletingTaskListReparentsItsTasksToTheDefault covers the +// other half of #310: deleting a Task List moves its Tasks to the default +// rather than destroying them. +func TestTaskHandler_DeletingTaskListReparentsItsTasksToTheDefault(t *testing.T) { + s := newTaskHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + inboxID := s.defaultTaskListID(t, token, workspaceID) + work := s.createTaskList(t, token, workspaceID, "Work", "#12809CFF") + + task := s.createTask(t, token, workspaceID, work.ID, "Ship the feature") + + deleteResp := s.do(t, http.MethodDelete, "/api/task-lists/"+strconv.FormatInt(work.ID, 10), token, workspaceID, nil) + defer deleteResp.Body.Close() + if deleteResp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204 deleting the task list, got %d", deleteResp.StatusCode) + } + + tasks := s.listTasks(t, token, workspaceID) + if len(tasks) != 1 || tasks[0].ID != task.ID { + t.Fatalf("expected the task to survive, got %v", tasks) + } + if tasks[0].TaskListID != inboxID { + t.Fatalf("expected the task to be reparented to the default task list %d, got %d", inboxID, tasks[0].TaskListID) + } +} diff --git a/server/internal/repository/task.go b/server/internal/repository/task.go new file mode 100644 index 0000000..3f726e2 --- /dev/null +++ b/server/internal/repository/task.go @@ -0,0 +1,196 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// Task is a Task (#310, ADR-0083): a unit of work a User tracks, belonging +// to exactly one Task List and private to its owner. Modelled on VTODO — +// Due and Start/DurationMinutes are two independent date axes, neither +// derived from the other. Completed is a nullable instant, never a status +// enum. +type Task struct { + ID int64 + UserID int64 + WorkspaceID int64 + TaskListID int64 + Title string + Notes string + Due *time.Time + Start *time.Time + DurationMinutes *int + Priority int + CompletedAt *time.Time + CreatedAt time.Time +} + +type TaskRepository struct { + db DBTX +} + +func NewTaskRepository(db *sql.DB) *TaskRepository { + return &TaskRepository{db: db} +} + +// WithTx returns a copy of the repository bound to tx. +func (r *TaskRepository) WithTx(tx *sql.Tx) *TaskRepository { + return &TaskRepository{db: tx} +} + +const taskColumns = `id, user_id, workspace_id, task_list_id, title, notes, due, start, duration_minutes, priority, completed_at, created_at` + +// Create inserts a new, incomplete Task titled title into taskListID, owned +// by userID inside workspaceID. Every other column (notes, due, start, +// duration, priority) lands empty/null — this ticket's quick-add only ever +// states a title (#310, ADR-0083). +func (r *TaskRepository) Create(ctx context.Context, userID, workspaceID, taskListID int64, title string) (Task, error) { + res, err := r.db.ExecContext(ctx, + `INSERT INTO tasks (user_id, workspace_id, task_list_id, title) VALUES (?, ?, ?, ?)`, + userID, workspaceID, taskListID, title, + ) + if err != nil { + return Task{}, fmt.Errorf("insert task: %w", err) + } + + id, err := res.LastInsertId() + if err != nil { + return Task{}, fmt.Errorf("get inserted task id: %w", err) + } + + return r.GetByID(ctx, id, userID, workspaceID) +} + +// GetByID returns the Task identified by id, scoped to userID and +// workspaceID together — a Task belonging to another User, or to a +// different Workspace, is ErrNotFound rather than any error naming the +// mismatch, so the caller can never distinguish "not mine" from "doesn't +// exist". +func (r *TaskRepository) GetByID(ctx context.Context, id, userID, workspaceID int64) (Task, error) { + row := r.db.QueryRowContext(ctx, + `SELECT `+taskColumns+` FROM tasks WHERE id = ? AND user_id = ? AND workspace_id = ?`, + id, userID, workspaceID, + ) + task, err := scanTaskRow(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Task{}, ErrNotFound + } + return Task{}, fmt.Errorf("scan task: %w", err) + } + return task, nil +} + +// ListIncomplete returns every incomplete Task userID owns inside +// workspaceID — deliberately not windowed by date (ADR-0083): a Task with +// no Deadline and no Time block has no window to be in. +func (r *TaskRepository) ListIncomplete(ctx context.Context, userID, workspaceID int64) ([]Task, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT `+taskColumns+` FROM tasks WHERE user_id = ? AND workspace_id = ? AND completed_at IS NULL ORDER BY created_at, id`, + userID, workspaceID, + ) + if err != nil { + return nil, fmt.Errorf("list incomplete tasks: %w", err) + } + return collectRows(rows, scanTaskRow) +} + +// ListCompleted returns userID's most recently completed Tasks inside +// workspaceID, newest first, bounded to limit rows — completed Tasks are +// returned only when asked for, and never as all history (ADR-0083). +func (r *TaskRepository) ListCompleted(ctx context.Context, userID, workspaceID, limit int64) ([]Task, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT `+taskColumns+` FROM tasks WHERE user_id = ? AND workspace_id = ? AND completed_at IS NOT NULL ORDER BY completed_at DESC, id DESC LIMIT ?`, + userID, workspaceID, limit, + ) + if err != nil { + return nil, fmt.Errorf("list completed tasks: %w", err) + } + return collectRows(rows, scanTaskRow) +} + +func scanTaskRow(row rowScanner) (Task, error) { + var t Task + err := row.Scan(&t.ID, &t.UserID, &t.WorkspaceID, &t.TaskListID, &t.Title, &t.Notes, &t.Due, &t.Start, &t.DurationMinutes, &t.Priority, &t.CompletedAt, &t.CreatedAt) + return t, err +} + +// UpdateTitle changes id's title, scoped to userID and workspaceID like +// GetByID. +func (r *TaskRepository) UpdateTitle(ctx context.Context, id, userID, workspaceID int64, title string) (Task, error) { + res, err := r.db.ExecContext(ctx, + `UPDATE tasks SET title = ? WHERE id = ? AND user_id = ? AND workspace_id = ?`, + title, id, userID, workspaceID, + ) + if err != nil { + return Task{}, fmt.Errorf("update task title: %w", err) + } + if err := requireAffected(res); err != nil { + return Task{}, err + } + return r.GetByID(ctx, id, userID, workspaceID) +} + +// Complete sets id's completed_at to now, scoped to userID and workspaceID +// like GetByID. Idempotent: completing an already-completed Task just +// refreshes the instant, since the caller (a single click, ADR-0068) never +// needs to distinguish the two. +func (r *TaskRepository) Complete(ctx context.Context, id, userID, workspaceID int64) (Task, error) { + res, err := r.db.ExecContext(ctx, + `UPDATE tasks SET completed_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ? AND workspace_id = ?`, + id, userID, workspaceID, + ) + if err != nil { + return Task{}, fmt.Errorf("complete task: %w", err) + } + if err := requireAffected(res); err != nil { + return Task{}, err + } + return r.GetByID(ctx, id, userID, workspaceID) +} + +// Uncomplete clears id's completed_at, scoped to userID and workspaceID +// like GetByID. +func (r *TaskRepository) Uncomplete(ctx context.Context, id, userID, workspaceID int64) (Task, error) { + res, err := r.db.ExecContext(ctx, + `UPDATE tasks SET completed_at = NULL WHERE id = ? AND user_id = ? AND workspace_id = ?`, + id, userID, workspaceID, + ) + if err != nil { + return Task{}, fmt.Errorf("uncomplete task: %w", err) + } + if err := requireAffected(res); err != nil { + return Task{}, err + } + return r.GetByID(ctx, id, userID, workspaceID) +} + +// Delete removes id outright, scoped to userID and workspaceID like GetByID. +func (r *TaskRepository) Delete(ctx context.Context, id, userID, workspaceID int64) error { + res, err := r.db.ExecContext(ctx, + `DELETE FROM tasks WHERE id = ? AND user_id = ? AND workspace_id = ?`, + id, userID, workspaceID, + ) + if err != nil { + return fmt.Errorf("delete task: %w", err) + } + return requireAffected(res) +} + +// ReparentToDefault moves every Task in fromListID, scoped to userID and +// workspaceID, into toListID — TaskListService.Delete's first step, run in +// the same transaction as the Task List's own removal, so deleting a +// container never silently deletes work (#310, ADR-0083). Zero rows moved +// is not an error: the Task List being deleted may hold no Tasks at all. +func (r *TaskRepository) ReparentToDefault(ctx context.Context, fromListID, toListID, userID, workspaceID int64) error { + if _, err := r.db.ExecContext(ctx, + `UPDATE tasks SET task_list_id = ? WHERE task_list_id = ? AND user_id = ? AND workspace_id = ?`, + toListID, fromListID, userID, workspaceID, + ); err != nil { + return fmt.Errorf("reparent tasks to default task list: %w", err) + } + return nil +} diff --git a/server/internal/repository/task_list.go b/server/internal/repository/task_list.go index f26a8cb..89552e8 100644 --- a/server/internal/repository/task_list.go +++ b/server/internal/repository/task_list.go @@ -97,6 +97,25 @@ func scanTaskListRow(row rowScanner) (TaskList, error) { return l, err } +// GetDefault returns the Task List currently holding the default flag for +// (userID, workspaceID) — exactly one always does (ADR-0083). Used by +// TaskListService.Delete to find where a deleted Task List's Tasks are +// reparented to. +func (r *TaskListRepository) GetDefault(ctx context.Context, userID, workspaceID int64) (TaskList, error) { + row := r.db.QueryRowContext(ctx, + `SELECT `+taskListColumns+` FROM task_lists WHERE user_id = ? AND workspace_id = ? AND is_default = 1`, + userID, workspaceID, + ) + list, err := scanTaskListRow(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return TaskList{}, ErrNotFound + } + return TaskList{}, fmt.Errorf("scan default task list: %w", err) + } + return list, nil +} + // Rename updates id's name, scoped to userID and workspaceID like GetByID. func (r *TaskListRepository) Rename(ctx context.Context, id, userID, workspaceID int64, name string) (TaskList, error) { res, err := r.db.ExecContext(ctx, diff --git a/server/internal/router/router.go b/server/internal/router/router.go index c45bdfa..5f6f92f 100644 --- a/server/internal/router/router.go +++ b/server/internal/router/router.go @@ -15,7 +15,7 @@ import ( "github.com/XiovV/calich/server/internal/static" ) -func New(logger *slog.Logger, authHandler *handlers.AuthHandler, calendarHandler *handlers.CalendarHandler, eventHandler *handlers.EventHandler, attachmentHandler *handlers.AttachmentHandler, notificationHandler *handlers.NotificationHandler, appPasswordHandler *handlers.AppPasswordHandler, accountHandler *handlers.AccountHandler, userHandler *handlers.UserHandler, workspaceHandler *handlers.WorkspaceHandler, groupHandler *handlers.GroupHandler, calendarSetHandler *handlers.CalendarSetHandler, taskListHandler *handlers.TaskListHandler, connectionHandler *handlers.ConnectionHandler, calDAVHandler http.Handler, authenticator httpauth.Authenticator, activeUserChecker httpauth.ActiveUserChecker, calDAVAuthenticator httpauth.CalDAVAuthenticator, calDAVRateLimiter httpauth.CalDAVRateLimiter, enabledChecker httpauth.DisabledChecker, workspaceMembershipChecker httpauth.WorkspaceMembershipChecker) (http.Handler, error) { +func New(logger *slog.Logger, authHandler *handlers.AuthHandler, calendarHandler *handlers.CalendarHandler, eventHandler *handlers.EventHandler, attachmentHandler *handlers.AttachmentHandler, notificationHandler *handlers.NotificationHandler, appPasswordHandler *handlers.AppPasswordHandler, accountHandler *handlers.AccountHandler, userHandler *handlers.UserHandler, workspaceHandler *handlers.WorkspaceHandler, groupHandler *handlers.GroupHandler, calendarSetHandler *handlers.CalendarSetHandler, taskListHandler *handlers.TaskListHandler, taskHandler *handlers.TaskHandler, connectionHandler *handlers.ConnectionHandler, calDAVHandler http.Handler, authenticator httpauth.Authenticator, activeUserChecker httpauth.ActiveUserChecker, calDAVAuthenticator httpauth.CalDAVAuthenticator, calDAVRateLimiter httpauth.CalDAVRateLimiter, enabledChecker httpauth.DisabledChecker, workspaceMembershipChecker httpauth.WorkspaceMembershipChecker) (http.Handler, error) { r := chi.NewRouter() r.Use(requestLogger(logger)) r.Use(middleware.Recoverer) @@ -286,6 +286,26 @@ func New(logger *slog.Logger, authHandler *handlers.AuthHandler, calendarHandler r.Delete("/{id}", taskListHandler.Delete) }) + // Tasks (#310, ADR-0083): capture-and-tick-off, gated the same as + // Task Lists. Private outright — TaskService itself resolves every + // Task by (id, caller, active workspace) and refuses with + // ErrNotFound rather than any authority check, so no extra + // middleware gate is needed beyond RequireWorkspace. + r.Route("/tasks", func(r chi.Router) { + r.Use(httpauth.RequireAuth(authenticator)) + r.Use(httpauth.RequireActiveUser(activeUserChecker)) + r.Use(httpauth.RequireEnabledUser(enabledChecker)) + r.Use(httpauth.RequireWorkspace(workspaceMembershipChecker)) + + r.Get("/", taskHandler.List) + r.Get("/completed", taskHandler.ListCompleted) + r.Post("/", taskHandler.Create) + r.Patch("/{id}", taskHandler.Update) + r.Put("/{id}/complete", taskHandler.Complete) + r.Delete("/{id}/complete", taskHandler.Uncomplete) + r.Delete("/{id}", taskHandler.Delete) + }) + // Connections (#285, ADR-0050, ADR-0051): Callback sits outside // RequireAuth on purpose — Google's redirect back is an // unauthenticated top-level navigation carrying neither an diff --git a/server/internal/router/router_test.go b/server/internal/router/router_test.go index 803e61b..194b36a 100644 --- a/server/internal/router/router_test.go +++ b/server/internal/router/router_test.go @@ -29,7 +29,7 @@ func newTestRouter(t *testing.T) http.Handler { t.Fatalf("bootstrap: %v", err) } - r, err := New(slog.New(slog.NewTextHandler(io.Discard, nil)), a.AuthHandler, a.CalendarHandler, a.EventHandler, a.AttachmentHandler, a.NotificationHandler, a.AppPasswordHandler, a.AccountHandler, a.UserHandler, a.WorkspaceHandler, a.GroupHandler, a.CalendarSetHandler, a.TaskListHandler, a.ConnectionHandler, a.CalDAVHandler, a.Auth, a.Auth, a.AppPasswords, a.RateLimiter, a.Auth, a.Workspaces) + r, err := New(slog.New(slog.NewTextHandler(io.Discard, nil)), a.AuthHandler, a.CalendarHandler, a.EventHandler, a.AttachmentHandler, a.NotificationHandler, a.AppPasswordHandler, a.AccountHandler, a.UserHandler, a.WorkspaceHandler, a.GroupHandler, a.CalendarSetHandler, a.TaskListHandler, a.TaskHandler, a.ConnectionHandler, a.CalDAVHandler, a.Auth, a.Auth, a.AppPasswords, a.RateLimiter, a.Auth, a.Workspaces) if err != nil { t.Fatalf("New: %v", err) } diff --git a/server/internal/service/graph.go b/server/internal/service/graph.go index 731655b..f51d690 100644 --- a/server/internal/service/graph.go +++ b/server/internal/service/graph.go @@ -67,6 +67,7 @@ type Graph struct { GroupRepo *repository.GroupRepository CalendarSetRepo *repository.CalendarSetRepository TaskListRepo *repository.TaskListRepository + TaskRepo *repository.TaskRepository NotificationRepo *repository.NotificationRepository AppPasswordRepo *repository.AppPasswordRepository FiredReminderRepo *repository.FiredReminderRepository @@ -94,6 +95,7 @@ type Graph struct { Groups *GroupService CalendarSets *CalendarSetService TaskLists *TaskListService + Tasks *TaskService Imports *ImportService Notifications *NotificationService Subscriptions *SubscribeService @@ -222,6 +224,7 @@ func NewGraph(sqlDB *sql.DB, cfg config.Config, opts ...GraphOption) (*Graph, er GroupRepo: repository.NewGroupRepository(sqlDB), CalendarSetRepo: repository.NewCalendarSetRepository(sqlDB), TaskListRepo: repository.NewTaskListRepository(sqlDB), + TaskRepo: repository.NewTaskRepository(sqlDB), NotificationRepo: repository.NewNotificationRepository(sqlDB), AppPasswordRepo: repository.NewAppPasswordRepository(sqlDB), FiredReminderRepo: repository.NewFiredReminderRepository(sqlDB), @@ -231,7 +234,8 @@ func NewGraph(sqlDB *sql.DB, cfg config.Config, opts ...GraphOption) (*Graph, er g.RateLimiter = NewAuthRateLimiter(g.RateLimitRepo, cfg.AuthRateLimitPerEmail, cfg.AuthRateLimitPerIP, cfg.RegisterRateLimitPerIP) g.Workspaces = NewWorkspaceService(sqlDB, g.WorkspaceRepo, g.WorkspaceInviteRepo, g.CalendarRepo, g.ShareRepo, g.TaskListRepo) - g.TaskLists = NewTaskListService(sqlDB, g.TaskListRepo) + g.TaskLists = NewTaskListService(sqlDB, g.TaskListRepo, g.TaskRepo) + g.Tasks = NewTaskService(g.TaskRepo, g.TaskListRepo) g.Groups = NewGroupService(g.GroupRepo, g.WorkspaceRepo) g.Calendars = NewCalendarService(sqlDB, g.CalendarRepo, g.SourceRepo, g.ShareRepo, g.UserRepo, g.EventReminderRepo, g.DefaultReminderRepo, g.ExplicitReminderRepo, g.ColorOverrideRepo, g.ExposureRepo, g.WorkspaceRepo, g.GroupShareRepo, g.GroupRepo) // CalendarSets needs Calendars for AddCalendar's Access check (#302, diff --git a/server/internal/service/task.go b/server/internal/service/task.go new file mode 100644 index 0000000..671260a --- /dev/null +++ b/server/internal/service/task.go @@ -0,0 +1,116 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/XiovV/calich/server/internal/repository" +) + +// completedTaskTail bounds how many completed Tasks ListCompleted returns +// (ADR-0083): a recent tail, never all history. +const completedTaskTail = 50 + +// ErrInvalidTaskTitle is returned by TaskService.Create and Update when +// title is empty. +var ErrInvalidTaskTitle = errors.New("task title must not be empty") + +// TaskService creates, lists, updates, completes/uncompletes and deletes +// Tasks (#310, ADR-0083). Private outright, like TaskListService — every +// method is scoped to the caller's own userID and workspaceID by the +// repository call itself, and a Task belonging to someone else surfaces as +// repository.ErrNotFound. +type TaskService struct { + tasks *repository.TaskRepository + taskLists *repository.TaskListRepository +} + +func NewTaskService(tasks *repository.TaskRepository, taskLists *repository.TaskListRepository) *TaskService { + return &TaskService{tasks: tasks, taskLists: taskLists} +} + +// ListIncomplete returns every incomplete Task userID owns inside +// workspaceID, unwindowed (ADR-0083). +func (s *TaskService) ListIncomplete(ctx context.Context, userID, workspaceID int64) ([]repository.Task, error) { + tasks, err := s.tasks.ListIncomplete(ctx, userID, workspaceID) + if err != nil { + return nil, fmt.Errorf("list incomplete tasks: %w", err) + } + return tasks, nil +} + +// ListCompleted returns userID's most recently completed Tasks inside +// workspaceID, bounded to completedTaskTail. +func (s *TaskService) ListCompleted(ctx context.Context, userID, workspaceID int64) ([]repository.Task, error) { + tasks, err := s.tasks.ListCompleted(ctx, userID, workspaceID, completedTaskTail) + if err != nil { + return nil, fmt.Errorf("list completed tasks: %w", err) + } + return tasks, nil +} + +// Create makes a new, incomplete Task titled title in taskListID, owned by +// userID inside workspaceID. taskListID must already be the caller's own — +// quick-add resolves it client-side (the default Task List, or the single +// checked Task List) before ever calling this, but the ownership check here +// is what stops a caller from filing a Task into someone else's Task List by +// typing a different id. +func (s *TaskService) Create(ctx context.Context, userID, workspaceID, taskListID int64, title string) (repository.Task, error) { + title = strings.TrimSpace(title) + if title == "" { + return repository.Task{}, ErrInvalidTaskTitle + } + + if _, err := s.taskLists.GetByID(ctx, taskListID, userID, workspaceID); err != nil { + return repository.Task{}, fmt.Errorf("get task list: %w", err) + } + + task, err := s.tasks.Create(ctx, userID, workspaceID, taskListID, title) + if err != nil { + return repository.Task{}, fmt.Errorf("create task: %w", err) + } + return task, nil +} + +// Update changes id's title, scoped to userID and workspaceID. +func (s *TaskService) Update(ctx context.Context, userID, workspaceID, id int64, title string) (repository.Task, error) { + title = strings.TrimSpace(title) + if title == "" { + return repository.Task{}, ErrInvalidTaskTitle + } + + task, err := s.tasks.UpdateTitle(ctx, id, userID, workspaceID, title) + if err != nil { + return repository.Task{}, fmt.Errorf("update task: %w", err) + } + return task, nil +} + +// Complete marks id completed, scoped to userID and workspaceID, written +// with no confirmation (ADR-0068). +func (s *TaskService) Complete(ctx context.Context, userID, workspaceID, id int64) (repository.Task, error) { + task, err := s.tasks.Complete(ctx, id, userID, workspaceID) + if err != nil { + return repository.Task{}, fmt.Errorf("complete task: %w", err) + } + return task, nil +} + +// Uncomplete un-marks id completed, scoped to userID and workspaceID. +func (s *TaskService) Uncomplete(ctx context.Context, userID, workspaceID, id int64) (repository.Task, error) { + task, err := s.tasks.Uncomplete(ctx, id, userID, workspaceID) + if err != nil { + return repository.Task{}, fmt.Errorf("uncomplete task: %w", err) + } + return task, nil +} + +// Delete removes id outright, scoped to userID and workspaceID. +func (s *TaskService) Delete(ctx context.Context, userID, workspaceID, id int64) error { + if err := s.tasks.Delete(ctx, id, userID, workspaceID); err != nil { + return fmt.Errorf("delete task: %w", err) + } + return nil +} diff --git a/server/internal/service/task_list.go b/server/internal/service/task_list.go index 70de97c..713d6ce 100644 --- a/server/internal/service/task_list.go +++ b/server/internal/service/task_list.go @@ -35,10 +35,11 @@ var ( type TaskListService struct { db *sql.DB taskLists *repository.TaskListRepository + tasks *repository.TaskRepository } -func NewTaskListService(db *sql.DB, taskLists *repository.TaskListRepository) *TaskListService { - return &TaskListService{db: db, taskLists: taskLists} +func NewTaskListService(db *sql.DB, taskLists *repository.TaskListRepository, tasks *repository.TaskRepository) *TaskListService { + return &TaskListService{db: db, taskLists: taskLists, tasks: tasks} } // ListForUser returns every Task List userID owns inside workspaceID. @@ -156,7 +157,9 @@ func (s *TaskListService) SetDefault(ctx context.Context, userID, workspaceID, i // Delete removes id outright, scoped to userID and workspaceID, refusing // while it holds the default flag (ADR-0083) — promote another Task List to -// default first via SetDefault. +// default first via SetDefault. Its Tasks are reparented to the caller's +// default Task List in the same transaction as the delete (#310, ADR-0083), +// so deleting a container never silently deletes work. func (s *TaskListService) Delete(ctx context.Context, userID, workspaceID, id int64) error { list, err := s.taskLists.GetByID(ctx, id, userID, workspaceID) if err != nil { @@ -166,7 +169,21 @@ func (s *TaskListService) Delete(ctx context.Context, userID, workspaceID, id in return ErrCannotDeleteDefaultTaskList } - if err := s.taskLists.Delete(ctx, id, userID, workspaceID); err != nil { + defaultList, err := s.taskLists.GetDefault(ctx, userID, workspaceID) + if err != nil { + return fmt.Errorf("get default task list: %w", err) + } + + err = repository.WithTx(ctx, s.db, func(tx *sql.Tx) error { + txTasks := s.tasks.WithTx(tx) + txLists := s.taskLists.WithTx(tx) + + if err := txTasks.ReparentToDefault(ctx, id, defaultList.ID, userID, workspaceID); err != nil { + return err + } + return txLists.Delete(ctx, id, userID, workspaceID) + }) + if err != nil { return fmt.Errorf("delete task list: %w", err) } return nil diff --git a/src/components/layout/TaskCompletionControl.tsx b/src/components/layout/TaskCompletionControl.tsx new file mode 100644 index 0000000..5953ff3 --- /dev/null +++ b/src/components/layout/TaskCompletionControl.tsx @@ -0,0 +1,32 @@ +import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox"; +import { Check } from "lucide-react"; + +interface TaskCompletionControlProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + "aria-label": string; +} + +// TaskCompletionControl is the circular completion control every Task row +// carries (#310, ADR-0068, ADR-0083): one click completes with no +// confirmation, another un-completes. Circular so it reads as "done" rather +// than "selected" — Checkbox.tsx's square shape is reserved for selection, +// this is the Completion glossary entry's own control. +export function TaskCompletionControl({ + checked, + onCheckedChange, + "aria-label": ariaLabel, +}: TaskCompletionControlProps) { + return ( + + + + + + ); +} diff --git a/src/components/layout/TaskQuickAdd.tsx b/src/components/layout/TaskQuickAdd.tsx new file mode 100644 index 0000000..23a5840 --- /dev/null +++ b/src/components/layout/TaskQuickAdd.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import { useShellStore } from "../../lib/shellStore"; +import { useTaskListsStore } from "../../lib/taskListsStore"; +import { resolveQuickAddTaskListId, useTasksStore } from "../../lib/tasksStore"; +import { toast } from "../../lib/toast"; + +// TaskQuickAdd is the Tasks panel's capture field (#310): type a title, +// press Enter, and the Task appears — no dialog, no Save button. Commits to +// the default Task List, or the single checked Task List when exactly one +// is checked in the Lists filter above it (resolveQuickAddTaskListId). +export function TaskQuickAdd() { + const [title, setTitle] = useState(""); + const taskLists = useTaskListsStore((state) => state.taskLists); + const checkedTaskListIds = useShellStore((state) => state.checkedTaskListIds); + const createTask = useTasksStore((state) => state.createTask); + + async function handleCommit() { + const trimmed = title.trim(); + if (!trimmed) return; + + const taskListId = resolveQuickAddTaskListId(taskLists, checkedTaskListIds); + if (taskListId === null) return; + + setTitle(""); + try { + await createTask(trimmed, taskListId); + } catch { + toast.error("Couldn't create the task."); + } + } + + return ( + setTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") handleCommit(); + }} + placeholder="Add a task" + aria-label="Add a task" + className="w-full rounded-shell-sm border border-border bg-surface px-2 py-1.5 text-body text-ink outline-none focus:ring-1 focus:ring-accent-ink" + /> + ); +} diff --git a/src/components/layout/TaskRow.tsx b/src/components/layout/TaskRow.tsx new file mode 100644 index 0000000..98e8818 --- /dev/null +++ b/src/components/layout/TaskRow.tsx @@ -0,0 +1,34 @@ +import type { Task } from "../../lib/tasksApi"; +import { useTasksStore } from "../../lib/tasksStore"; +import { TaskCompletionControl } from "./TaskCompletionControl"; + +interface TaskRowProps { + task: Task; +} + +// TaskRow is one row in the Tasks panel's flat list (#310, ADR-0083). No +// Deadlines yet, so every Task sits under "No date" with nothing to group +// by — this ticket's demoable end state is capture, tick off, and show/hide +// completed, not the bucketed/grouped panel ADR-0083 describes eventually. +export function TaskRow({ task }: TaskRowProps) { + const setTaskCompleted = useTasksStore((state) => state.setTaskCompleted); + + return ( +
    + { + void setTaskCompleted(task.id, checked); + }} + aria-label={task.completed ? `Mark "${task.title}" incomplete` : `Mark "${task.title}" complete`} + /> + + {task.title} + +
    + ); +} diff --git a/src/components/layout/TasksPanel.tsx b/src/components/layout/TasksPanel.tsx index c2a6036..5d1b3ab 100644 --- a/src/components/layout/TasksPanel.tsx +++ b/src/components/layout/TasksPanel.tsx @@ -1,17 +1,43 @@ -import { X } from "lucide-react"; +import { useEffect } from "react"; +import { ChevronDown, ChevronRight, X } from "lucide-react"; import { useShellStore } from "../../lib/shellStore"; +import { useTasksStore } from "../../lib/tasksStore"; +import { useWorkspacesStore } from "../../lib/workspacesStore"; import { IconButton } from "../ui/IconButton"; import { TaskListsFilter } from "./TaskListsFilter"; +import { TaskQuickAdd } from "./TaskQuickAdd"; +import { TaskRow } from "./TaskRow"; -// TasksPanel is the right-hand panel holding a User's Tasks (#317, +// TasksPanel is the right-hand panel holding a User's Tasks (#310, #317, // ADR-0083): toggled from the top bar's Tasks button, closed by default, -// squeezing the grid rather than overlaying it (AppShell renders it as a -// flex sibling of
    , exactly like the left Sidebar). This ticket -// delivers the containers (Task Lists) and the panel they live in — no Task -// exists yet, so the body below the Lists filter is a placeholder until a -// later ticket adds Tasks themselves. +// squeezing the grid rather than overlaying it. No Deadlines yet, so every +// Task renders in one flat list — the demoable end state is capture, tick +// off, and show/hide completed (#310); the bucketed/grouped panel ADR-0083 +// describes is a later ticket. export function TasksPanel() { const setTasksPanelOpen = useShellStore((state) => state.setTasksPanelOpen); + const tasks = useTasksStore((state) => state.tasks); + const completedTasks = useTasksStore((state) => state.completedTasks); + const fetchTasks = useTasksStore((state) => state.fetchTasks); + const fetchCompletedTasks = useTasksStore((state) => state.fetchCompletedTasks); + const showCompletedTasks = useShellStore((state) => state.showCompletedTasks); + const setShowCompletedTasks = useShellStore((state) => state.setShowCompletedTasks); + const activeWorkspaceId = useWorkspacesStore((state) => state.activeWorkspaceId); + + useEffect(() => { + // Refetches on mount and whenever the active Workspace changes, same + // posture as TaskListsFilter's own effect — a failure here costs the + // panel its Tasks and nothing else. + if (activeWorkspaceId === null) return; + fetchTasks().catch(() => {}); + }, [activeWorkspaceId, fetchTasks]); + + useEffect(() => { + // Completed Tasks are fetched only once asked for (ADR-0083) — opening + // "Show completed" is what triggers this, not mounting the panel. + if (!showCompletedTasks || activeWorkspaceId === null) return; + fetchCompletedTasks().catch(() => {}); + }, [showCompletedTasks, activeWorkspaceId, fetchCompletedTasks]); return (
    @@ -26,7 +52,33 @@ export function TasksPanel() {
    -

    No tasks yet.

    + +
    + {tasks.length === 0 ? ( +

    No tasks yet.

    + ) : ( + tasks.map((task) => ) + )} +
    + + {showCompletedTasks && ( +
    + {completedTasks.map((task) => ( + + ))} +
    + )}
    ); } diff --git a/src/lib/shellStore.ts b/src/lib/shellStore.ts index 399131b..6032638 100644 --- a/src/lib/shellStore.ts +++ b/src/lib/shellStore.ts @@ -34,6 +34,10 @@ interface ShellState { // rule via reconcileCheckedIds (#317, ADR-0083). checkedTaskListIds: Set; knownTaskListIds: Set; + // showCompletedTasks is "Show completed" (#310, ADR-0083): session state + // like tasksPanelOpen, closed (hidden) by default and with no Preference + // behind it — reopening the panel later starts hidden again. + showCompletedTasks: boolean; // requestedEventId is set by a click on an invite Notification (the // NotificationBell has no reach into AppShell's own eventModalState) and // cleared once AppShell has resolved it into an opened EventModal — a @@ -67,6 +71,7 @@ interface ShellState { reconcileCheckedTaskListIds: (ids: Iterable) => void; toggleTaskListChecked: (id: number) => void; addCheckedTaskListId: (id: number) => void; + setShowCompletedTasks: (show: boolean) => void; } export const useShellStore = create((set) => ({ @@ -90,6 +95,7 @@ export const useShellStore = create((set) => ({ // function's body. checkedTaskListIds: new Set(), knownTaskListIds: new Set(), + showCompletedTasks: false, requestedEventId: null, requestEventOpen: (eventId) => set({ requestedEventId: eventId }), clearRequestedEventOpen: () => set({ requestedEventId: null }), @@ -152,6 +158,7 @@ export const useShellStore = create((set) => ({ checkedTaskListIds: new Set(state.checkedTaskListIds).add(id), knownTaskListIds: new Set(state.knownTaskListIds).add(id), })), + setShowCompletedTasks: (show) => set({ showCompletedTasks: show }), })); // Shared by every caller that refetches Calendars and needs the checked set diff --git a/src/lib/tasksApi.ts b/src/lib/tasksApi.ts new file mode 100644 index 0000000..45a3ca2 --- /dev/null +++ b/src/lib/tasksApi.ts @@ -0,0 +1,103 @@ +import { authedFetch, errorFromResponse } from "./apiClient"; +import { workspaceHeaders } from "./workspaceHeaders"; + +// Tasks (#310, ADR-0083): a unit of work a User tracks, belonging to +// exactly one Task List and private to its owner. Scoped to the active +// Workspace via workspaceHeaders, same as every other Workspace-scoped +// call. This ticket only ever reads/writes title and completed — notes, +// due, start and priority exist on the server but nothing here sends them +// yet. + +export interface Task { + id: number; + taskListId: number; + title: string; + completed: boolean; +} + +export const tasksApi = { + // Every incomplete Task the caller owns in the active Workspace, + // unwindowed by date. + async list(accessToken: string): Promise { + const response = await authedFetch(accessToken, "/api/tasks/", { + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as Task[]; + }, + + // The caller's most recently completed Tasks, bounded to a recent tail + // rather than all history — fetched only when "Show completed" asks. + async listCompleted(accessToken: string): Promise { + const response = await authedFetch(accessToken, "/api/tasks/completed", { + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as Task[]; + }, + + // Creates a Task titled title in taskListId — the quick-add field's + // commit-on-Enter. + async create(accessToken: string, title: string, taskListId: number): Promise { + const response = await authedFetch(accessToken, "/api/tasks/", { + method: "POST", + credentials: "include", + headers: workspaceHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ title, taskListId }), + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as Task; + }, + + // Renames id. + async update(accessToken: string, id: number, title: string): Promise { + const response = await authedFetch(accessToken, `/api/tasks/${id}`, { + method: "PATCH", + credentials: "include", + headers: workspaceHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ title }), + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as Task; + }, + + // Marks id completed. + async complete(accessToken: string, id: number): Promise { + const response = await authedFetch(accessToken, `/api/tasks/${id}/complete`, { + method: "PUT", + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as Task; + }, + + // Un-marks id completed. + async uncomplete(accessToken: string, id: number): Promise { + const response = await authedFetch(accessToken, `/api/tasks/${id}/complete`, { + method: "DELETE", + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as Task; + }, + + // Deletes id outright. + async remove(accessToken: string, id: number): Promise { + const response = await authedFetch(accessToken, `/api/tasks/${id}`, { + method: "DELETE", + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + }, +}; diff --git a/src/lib/tasksStore.test.ts b/src/lib/tasksStore.test.ts new file mode 100644 index 0000000..4705430 --- /dev/null +++ b/src/lib/tasksStore.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./tasksApi", () => ({ + tasksApi: { + list: vi.fn(), + listCompleted: vi.fn(), + create: vi.fn(), + update: vi.fn(), + complete: vi.fn(), + uncomplete: vi.fn(), + remove: vi.fn(), + }, +})); + +vi.mock("./toast", () => ({ + toast: { error: vi.fn() }, +})); + +const { tasksApi } = await import("./tasksApi"); +const { toast } = await import("./toast"); +const { useAuthStore } = await import("./authStore"); +const { useTasksStore, resolveQuickAddTaskListId } = await import("./tasksStore"); + +const inbox = { id: 1, name: "Inbox", color: "#12809CFF", isDefault: true }; +const work = { id: 2, name: "Work", color: "#E2483DFF", isDefault: false }; +const personal = { id: 3, name: "Personal", color: "#4A90D9FF", isDefault: false }; + +const buyMilk = { id: 10, taskListId: inbox.id, title: "Buy milk", completed: false }; + +function resetStore() { + useTasksStore.setState({ tasks: [], completedTasks: [] }); + useAuthStore.setState({ accessToken: "token-123" }); +} + +beforeEach(() => { + vi.clearAllMocks(); + resetStore(); +}); + +describe("resolveQuickAddTaskListId", () => { + it("targets the default task list when nothing is checked", () => { + expect(resolveQuickAddTaskListId([inbox, work, personal], new Set())).toBe(inbox.id); + }); + + it("targets the default task list when more than one is checked", () => { + expect(resolveQuickAddTaskListId([inbox, work, personal], new Set([work.id, personal.id]))).toBe( + inbox.id, + ); + }); + + it("targets the single checked task list when exactly one is checked", () => { + expect(resolveQuickAddTaskListId([inbox, work, personal], new Set([work.id]))).toBe(work.id); + }); + + it("ignores a stale checked id no longer present among the task lists", () => { + expect(resolveQuickAddTaskListId([inbox, work], new Set([personal.id]))).toBe(inbox.id); + }); +}); + +describe("fetchTasks", () => { + it("loads incomplete tasks from the API", async () => { + vi.mocked(tasksApi.list).mockResolvedValue([buyMilk]); + + await useTasksStore.getState().fetchTasks(); + + expect(useTasksStore.getState().tasks).toEqual([buyMilk]); + }); +}); + +describe("createTask", () => { + it("appends the task once the API call resolves", async () => { + vi.mocked(tasksApi.create).mockResolvedValue(buyMilk); + + const created = await useTasksStore.getState().createTask("Buy milk", inbox.id); + + expect(created).toEqual(buyMilk); + expect(useTasksStore.getState().tasks).toEqual([buyMilk]); + expect(tasksApi.create).toHaveBeenCalledWith("token-123", "Buy milk", inbox.id); + }); +}); + +describe("setTaskCompleted", () => { + it("moves the task to completedTasks immediately, before the API call resolves", () => { + useTasksStore.setState({ tasks: [buyMilk], completedTasks: [] }); + let resolveComplete: () => void = () => {}; + vi.mocked(tasksApi.complete).mockReturnValue( + new Promise((resolve) => { + resolveComplete = () => resolve({ ...buyMilk, completed: true }); + }), + ); + + const promise = useTasksStore.getState().setTaskCompleted(buyMilk.id, true); + + expect(useTasksStore.getState().tasks).toEqual([]); + expect(useTasksStore.getState().completedTasks).toEqual([{ ...buyMilk, completed: true }]); + + resolveComplete(); + return promise; + }); + + it("rolls back and shows a toast when completing fails", async () => { + useTasksStore.setState({ tasks: [buyMilk], completedTasks: [] }); + vi.mocked(tasksApi.complete).mockRejectedValue(new Error("network error")); + + const ok = await useTasksStore.getState().setTaskCompleted(buyMilk.id, true); + + expect(ok).toBe(false); + expect(useTasksStore.getState().tasks).toEqual([buyMilk]); + expect(useTasksStore.getState().completedTasks).toEqual([]); + expect(toast.error).toHaveBeenCalledWith("Couldn't complete the task."); + }); + + it("moves the task back to tasks immediately when un-completing", () => { + const completed = { ...buyMilk, completed: true }; + useTasksStore.setState({ tasks: [], completedTasks: [completed] }); + let resolveUncomplete: () => void = () => {}; + vi.mocked(tasksApi.uncomplete).mockReturnValue( + new Promise((resolve) => { + resolveUncomplete = () => resolve(buyMilk); + }), + ); + + const promise = useTasksStore.getState().setTaskCompleted(buyMilk.id, false); + + expect(useTasksStore.getState().completedTasks).toEqual([]); + expect(useTasksStore.getState().tasks).toEqual([{ ...completed, completed: false }]); + + resolveUncomplete(); + return promise; + }); + + it("rolls back and shows a toast when un-completing fails", async () => { + const completed = { ...buyMilk, completed: true }; + useTasksStore.setState({ tasks: [], completedTasks: [completed] }); + vi.mocked(tasksApi.uncomplete).mockRejectedValue(new Error("network error")); + + const ok = await useTasksStore.getState().setTaskCompleted(buyMilk.id, false); + + expect(ok).toBe(false); + expect(useTasksStore.getState().completedTasks).toEqual([completed]); + expect(useTasksStore.getState().tasks).toEqual([]); + expect(toast.error).toHaveBeenCalledWith("Couldn't reopen the task."); + }); + + it("resolves false and touches nothing when the task isn't in either list", async () => { + useTasksStore.setState({ tasks: [], completedTasks: [] }); + + const ok = await useTasksStore.getState().setTaskCompleted(999, true); + + expect(ok).toBe(false); + expect(tasksApi.complete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/tasksStore.ts b/src/lib/tasksStore.ts new file mode 100644 index 0000000..c91dd74 --- /dev/null +++ b/src/lib/tasksStore.ts @@ -0,0 +1,136 @@ +import { create } from "zustand"; +import { useAuthStore } from "./authStore"; +import { makeOptimisticWrite } from "./optimisticWrite"; +import { type Task, tasksApi } from "./tasksApi"; +import type { TaskList } from "./taskListsApi"; + +// The Tasks panel's own data source (#310, ADR-0083): every incomplete Task +// the caller owns in the active Workspace, plus a bounded tail of completed +// ones fetched only when "Show completed" asks. Mirrors taskListsStore's +// shape for fetch/create/update/delete (ADR-0067's server-first discipline: +// the surface showing the result is a dialog-less list, and each of those +// writes already knows nothing more than what the server hands back). +// Completion is the one write that IS optimistic — the click is the +// feedback, and the client already knows the result before asking +// (ADR-0067, ADR-0068). +interface TasksState { + tasks: Task[]; + completedTasks: Task[]; + fetchTasks: () => Promise; + fetchCompletedTasks: () => Promise; + createTask: (title: string, taskListId: number) => Promise; + updateTask: (id: number, title: string) => Promise; + deleteTask: (id: number) => Promise; + setTaskCompleted: (id: number, completed: boolean) => Promise; +} + +// resolveQuickAddTaskListId is quick-add's own targeting rule (#310): the +// single checked Task List when exactly one is checked in the Lists filter +// — the filter already states the User's intent — otherwise the default +// Task List. checkedTaskListIds is intersected with taskLists itself rather +// than trusted alone, so a stale checked id (a Task List deleted elsewhere, +// not yet reconciled) can't be resolved to. Returns null only if taskLists +// carries no default at all, which never happens in practice (ADR-0083: +// exactly one Task List per (User, Workspace) is always the default). +export function resolveQuickAddTaskListId( + taskLists: TaskList[], + checkedTaskListIds: Set, +): number | null { + const checked = taskLists.filter((list) => checkedTaskListIds.has(list.id)); + if (checked.length === 1) return checked[0].id; + + return taskLists.find((list) => list.isDefault)?.id ?? null; +} + +function requireAccessToken(): string { + const accessToken = useAuthStore.getState().accessToken; + if (!accessToken) throw new Error("Not authenticated."); + return accessToken; +} + +// Binds no Access-change policy (ADR-0067): a Task has no Calendar to name, +// and is private outright — there is no Share whose revocation could change +// what the caller can reach. +const write = makeOptimisticWrite(); + +export const useTasksStore = create((set, get) => ({ + tasks: [], + completedTasks: [], + + fetchTasks: async () => { + const tasks = await tasksApi.list(requireAccessToken()); + set({ tasks }); + }, + + fetchCompletedTasks: async () => { + const completedTasks = await tasksApi.listCompleted(requireAccessToken()); + set({ completedTasks }); + }, + + createTask: async (title, taskListId) => { + const created = await tasksApi.create(requireAccessToken(), title, taskListId); + set({ tasks: [...get().tasks, created] }); + return created; + }, + + updateTask: async (id, title) => { + const updated = await tasksApi.update(requireAccessToken(), id, title); + set({ tasks: get().tasks.map((t) => (t.id === id ? updated : t)) }); + }, + + deleteTask: async (id) => { + await tasksApi.remove(requireAccessToken(), id); + set({ + tasks: get().tasks.filter((t) => t.id !== id), + completedTasks: get().completedTasks.filter((t) => t.id !== id), + }); + }, + + // setTaskCompleted paints the toggle immediately and puts it back on + // failure (ADR-0067, ADR-0068) — no confirmation, one click either way. + // Completing moves the Task out of the incomplete list and into the + // completed one (and back, un-completing), since the two are separate + // fetches rather than one list filtered client-side (ADR-0083: completed + // Tasks are returned only when asked for). + setTaskCompleted: async (id, completed) => { + const task = get().tasks.find((t) => t.id === id) ?? get().completedTasks.find((t) => t.id === id); + if (!task) return false; + + return write({ + apply: () => { + if (completed) { + set((state) => ({ + tasks: state.tasks.filter((t) => t.id !== id), + completedTasks: [{ ...task, completed: true }, ...state.completedTasks], + })); + } else { + set((state) => ({ + completedTasks: state.completedTasks.filter((t) => t.id !== id), + tasks: [...state.tasks, { ...task, completed: false }], + })); + } + }, + revert: () => { + if (completed) { + set((state) => ({ + completedTasks: state.completedTasks.filter((t) => t.id !== id), + tasks: [...state.tasks, task], + })); + } else { + set((state) => ({ + tasks: state.tasks.filter((t) => t.id !== id), + completedTasks: [task, ...state.completedTasks], + })); + } + }, + dispatch: async () => { + if (completed) { + await tasksApi.complete(requireAccessToken(), id); + } else { + await tasksApi.uncomplete(requireAccessToken(), id); + } + }, + fallbackMessage: completed ? "Couldn't complete the task." : "Couldn't reopen the task.", + }); + }, +})); From 763079638e2cd3bd7a0fdf52d01d0bbdfc7a3859 Mon Sep 17 00:00:00 2001 From: XiovV Date: Fri, 11 Sep 2026 15:48:25 +0200 Subject: [PATCH 17/33] Fix Task List delete race and quick-add draft loss (#310) Read the default Task List inside the same transaction as the reparent and the delete, so a concurrent SetDefault can't land in between and reparent Tasks to a Task List that's no longer default by commit time. Quick-add now clears its field only once the create succeeds, instead of losing the typed title on a failed request. Also drops the unused update/delete wiring from the Task API client and store (no UI calls either yet) and adds the missing same-User-two-Workspaces scoping test for the Tasks list endpoint. --- server/internal/handlers/task_test.go | 21 +++++++++++++++++ server/internal/service/task_list.go | 14 +++++++---- src/components/layout/TaskQuickAdd.tsx | 5 +++- src/lib/tasksApi.ts | 26 +++------------------ src/lib/tasksStore.ts | 32 ++++++++------------------ 5 files changed, 47 insertions(+), 51 deletions(-) diff --git a/server/internal/handlers/task_test.go b/server/internal/handlers/task_test.go index 41525c5..b569a69 100644 --- a/server/internal/handlers/task_test.go +++ b/server/internal/handlers/task_test.go @@ -282,6 +282,27 @@ func TestTaskHandler_List_UnwindowedIncludesUndated(t *testing.T) { } } +// TestTaskHandler_List_ScopedToWorkspace covers the other half of scoping +// TestTaskHandler_SecondUserCannotReadUpdateCompleteOrDeleteFirstsTask +// doesn't: the same User, switching between two of their own Workspaces, +// rather than two different Users. +func TestTaskHandler_List_ScopedToWorkspace(t *testing.T) { + s := newTaskHandlerTestServer(t) + token, userID, firstWorkspaceID := s.register(t, "alice") + firstInboxID := s.defaultTaskListID(t, token, firstWorkspaceID) + s.createTask(t, token, firstWorkspaceID, firstInboxID, "Buy milk") + + secondWorkspace, err := s.graph.Workspaces.CreateForOwner(context.Background(), userID, "Second workspace") + if err != nil { + t.Fatalf("create second workspace: %v", err) + } + + tasks := s.listTasks(t, token, secondWorkspace.ID) + if len(tasks) != 0 { + t.Fatalf("expected no tasks from the first workspace to leak into the second, got %v", tasks) + } +} + func TestTaskHandler_List_ExcludesCompletedUnlessAsked(t *testing.T) { s := newTaskHandlerTestServer(t) token, _, workspaceID := s.register(t, "alice") diff --git a/server/internal/service/task_list.go b/server/internal/service/task_list.go index 713d6ce..56951ea 100644 --- a/server/internal/service/task_list.go +++ b/server/internal/service/task_list.go @@ -169,15 +169,19 @@ func (s *TaskListService) Delete(ctx context.Context, userID, workspaceID, id in return ErrCannotDeleteDefaultTaskList } - defaultList, err := s.taskLists.GetDefault(ctx, userID, workspaceID) - if err != nil { - return fmt.Errorf("get default task list: %w", err) - } - err = repository.WithTx(ctx, s.db, func(tx *sql.Tx) error { txTasks := s.tasks.WithTx(tx) txLists := s.taskLists.WithTx(tx) + // Read inside the same transaction as the reparent and the delete, + // not before it opens: reading which Task List is default earlier + // would let a concurrent SetDefault land in between, reparenting + // these Tasks to a Task List that is no longer the default by the + // time this transaction commits. + defaultList, err := txLists.GetDefault(ctx, userID, workspaceID) + if err != nil { + return err + } if err := txTasks.ReparentToDefault(ctx, id, defaultList.ID, userID, workspaceID); err != nil { return err } diff --git a/src/components/layout/TaskQuickAdd.tsx b/src/components/layout/TaskQuickAdd.tsx index 23a5840..6058114 100644 --- a/src/components/layout/TaskQuickAdd.tsx +++ b/src/components/layout/TaskQuickAdd.tsx @@ -21,9 +21,12 @@ export function TaskQuickAdd() { const taskListId = resolveQuickAddTaskListId(taskLists, checkedTaskListIds); if (taskListId === null) return; - setTitle(""); try { await createTask(trimmed, taskListId); + // Cleared only on success: clearing eagerly would lose the typed + // title on a failed create, with no dialog left open to recover it + // from. + setTitle(""); } catch { toast.error("Couldn't create the task."); } diff --git a/src/lib/tasksApi.ts b/src/lib/tasksApi.ts index 45a3ca2..919e27b 100644 --- a/src/lib/tasksApi.ts +++ b/src/lib/tasksApi.ts @@ -6,7 +6,9 @@ import { workspaceHeaders } from "./workspaceHeaders"; // Workspace via workspaceHeaders, same as every other Workspace-scoped // call. This ticket only ever reads/writes title and completed — notes, // due, start and priority exist on the server but nothing here sends them -// yet. +// yet. The server also supports update and delete (PATCH/DELETE +// /api/tasks/{id}), but no UI here calls either yet, so this client omits +// them until something needs to. export interface Task { id: number; @@ -54,19 +56,6 @@ export const tasksApi = { return (await response.json()) as Task; }, - // Renames id. - async update(accessToken: string, id: number, title: string): Promise { - const response = await authedFetch(accessToken, `/api/tasks/${id}`, { - method: "PATCH", - credentials: "include", - headers: workspaceHeaders({ "Content-Type": "application/json" }), - body: JSON.stringify({ title }), - }); - if (!response.ok) throw await errorFromResponse(response); - - return (await response.json()) as Task; - }, - // Marks id completed. async complete(accessToken: string, id: number): Promise { const response = await authedFetch(accessToken, `/api/tasks/${id}/complete`, { @@ -91,13 +80,4 @@ export const tasksApi = { return (await response.json()) as Task; }, - // Deletes id outright. - async remove(accessToken: string, id: number): Promise { - const response = await authedFetch(accessToken, `/api/tasks/${id}`, { - method: "DELETE", - credentials: "include", - headers: workspaceHeaders(), - }); - if (!response.ok) throw await errorFromResponse(response); - }, }; diff --git a/src/lib/tasksStore.ts b/src/lib/tasksStore.ts index c91dd74..e9d1f1f 100644 --- a/src/lib/tasksStore.ts +++ b/src/lib/tasksStore.ts @@ -6,21 +6,22 @@ import type { TaskList } from "./taskListsApi"; // The Tasks panel's own data source (#310, ADR-0083): every incomplete Task // the caller owns in the active Workspace, plus a bounded tail of completed -// ones fetched only when "Show completed" asks. Mirrors taskListsStore's -// shape for fetch/create/update/delete (ADR-0067's server-first discipline: -// the surface showing the result is a dialog-less list, and each of those -// writes already knows nothing more than what the server hands back). -// Completion is the one write that IS optimistic — the click is the -// feedback, and the client already knows the result before asking -// (ADR-0067, ADR-0068). +// ones fetched only when "Show completed" asks. fetchTasks/fetchCompletedTasks/ +// createTask mirror taskListsStore's server-first discipline (ADR-0067): the +// surface showing the result is a dialog-less list, and each of those writes +// already knows nothing more than what the server hands back. Completion is +// the one write that IS optimistic — the click is the feedback, and the +// client already knows the result before asking (ADR-0067, ADR-0068). +// update and delete exist on the server (/api/tasks supports both) but +// nothing in this ticket's UI calls either yet — no rename or delete +// affordance on a Task row — so this store exposes only what quick-add and +// the completion control actually use. interface TasksState { tasks: Task[]; completedTasks: Task[]; fetchTasks: () => Promise; fetchCompletedTasks: () => Promise; createTask: (title: string, taskListId: number) => Promise; - updateTask: (id: number, title: string) => Promise; - deleteTask: (id: number) => Promise; setTaskCompleted: (id: number, completed: boolean) => Promise; } @@ -73,19 +74,6 @@ export const useTasksStore = create((set, get) => ({ return created; }, - updateTask: async (id, title) => { - const updated = await tasksApi.update(requireAccessToken(), id, title); - set({ tasks: get().tasks.map((t) => (t.id === id ? updated : t)) }); - }, - - deleteTask: async (id) => { - await tasksApi.remove(requireAccessToken(), id); - set({ - tasks: get().tasks.filter((t) => t.id !== id), - completedTasks: get().completedTasks.filter((t) => t.id !== id), - }); - }, - // setTaskCompleted paints the toggle immediately and puts it back on // failure (ADR-0067, ADR-0068) — no confirmation, one click either way. // Completing moves the Task out of the incomplete list and into the From b257136cfb0359343f9367be8b91bd08c38dda99 Mon Sep 17 00:00:00 2001 From: XiovV Date: Fri, 11 Sep 2026 16:18:18 +0200 Subject: [PATCH 18/33] Deadlines, the detail surface, and Task buckets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Task gains a Deadline, notes and Priority (raw 0-9 VTODO value, mapped to None/Low/Medium/High for display), settable from a small detail surface opened by clicking a Task row, which also moves a Task to another Task List and deletes it. Deliberately not EventModal. taskScheduling.ts derives each Task's bucket (Overdue/Today/Upcoming/No date) from its Deadline against today in the Viewer zone, falling back to its Time block's start, at render only — nothing is stored. The panel now groups into these four buckets, each with a count and each row showing its own deadline; a completed Task shown via "Show completed" rejoins its own bucket rather than a separate pile. Fixes #311 --- server/internal/handlers/task.go | 163 ++++++++++++++- server/internal/handlers/task_test.go | 141 +++++++++++++ server/internal/repository/task.go | 82 ++++++++ server/internal/router/router.go | 5 + server/internal/service/task.go | 67 ++++++ .../layout/DeleteTaskConfirmation.tsx | 53 +++++ src/components/layout/TaskDetailModal.tsx | 192 ++++++++++++++++++ src/components/layout/TaskRow.tsx | 40 ++-- src/components/layout/TasksPanel.tsx | 65 ++++-- src/lib/taskPriority.test.ts | 42 ++++ src/lib/taskPriority.ts | 62 ++++++ src/lib/taskScheduling.test.ts | 173 ++++++++++++++++ src/lib/taskScheduling.ts | 124 +++++++++++ src/lib/tasksApi.ts | 132 +++++++++++- src/lib/tasksStore.test.ts | 76 ++++++- src/lib/tasksStore.ts | 75 ++++++- 16 files changed, 1425 insertions(+), 67 deletions(-) create mode 100644 src/components/layout/DeleteTaskConfirmation.tsx create mode 100644 src/components/layout/TaskDetailModal.tsx create mode 100644 src/lib/taskPriority.test.ts create mode 100644 src/lib/taskPriority.ts create mode 100644 src/lib/taskScheduling.test.ts create mode 100644 src/lib/taskScheduling.ts diff --git a/server/internal/handlers/task.go b/server/internal/handlers/task.go index 812637a..ec425fe 100644 --- a/server/internal/handlers/task.go +++ b/server/internal/handlers/task.go @@ -8,6 +8,7 @@ package handlers import ( "net/http" + "time" "github.com/XiovV/calich/server/internal/httpauth" "github.com/XiovV/calich/server/internal/httpresponse" @@ -24,27 +25,41 @@ func NewTaskHandler(tasks *service.TaskService) *TaskHandler { } type taskResponse struct { - ID int64 `json:"id"` - TaskListID int64 `json:"taskListId"` - Title string `json:"title"` - Completed bool `json:"completed"` + ID int64 `json:"id"` + TaskListID int64 `json:"taskListId"` + Title string `json:"title"` + Notes string `json:"notes"` + Due *time.Time `json:"due"` + Start *time.Time `json:"start"` + DurationMinutes *int `json:"durationMinutes"` + Priority int `json:"priority"` + Completed bool `json:"completed"` + CreatedAt time.Time `json:"createdAt"` } func toTaskResponse(t repository.Task) taskResponse { return taskResponse{ - ID: t.ID, - TaskListID: t.TaskListID, - Title: t.Title, - Completed: t.CompletedAt != nil, + ID: t.ID, + TaskListID: t.TaskListID, + Title: t.Title, + Notes: t.Notes, + Due: t.Due, + Start: t.Start, + DurationMinutes: t.DurationMinutes, + Priority: t.Priority, + Completed: t.CompletedAt != nil, + CreatedAt: t.CreatedAt, } } // taskErrors renders the sentinels every TaskService call can return: an -// empty title, and repository.ErrNotFound for a Task (or, on Create, a Task -// List) that doesn't exist, belongs to another User, or belongs to another -// Workspace — indistinguishable by design. +// empty title, an out-of-range Priority, and repository.ErrNotFound for a +// Task (or, on Create/Move, a Task List) that doesn't exist, belongs to +// another User, or belongs to another Workspace — indistinguishable by +// design. var taskErrors = []errorCase{ {service.ErrInvalidTaskTitle, badRequest(service.ErrInvalidTaskTitle.Error())}, + {service.ErrInvalidTaskPriority, badRequest(service.ErrInvalidTaskPriority.Error())}, {repository.ErrNotFound, notFound("task not found")}, } @@ -172,6 +187,132 @@ func (h *TaskHandler) Uncomplete(w http.ResponseWriter, r *http.Request) { httpresponse.JSON(w, http.StatusOK, toTaskResponse(task)) } +type updateTaskNotesRequest struct { + Notes string `json:"notes"` +} + +// UpdateNotes changes a Task's notes. +func (h *TaskHandler) UpdateNotes(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + id, ok := parseInt64Param(w, r, "id") + if !ok { + return + } + + req, ok := decodeJSON[updateTaskNotesRequest](w, r) + if !ok { + return + } + + task, err := h.tasks.UpdateNotes(r.Context(), userID, workspaceID, id, req.Notes) + if respondError(w, err, taskErrors, "failed to update task notes") { + return + } + + httpresponse.JSON(w, http.StatusOK, toTaskResponse(task)) +} + +type setTaskDeadlineRequest struct { + Due time.Time `json:"due"` +} + +// SetDeadline serves PUT /api/tasks/{id}/deadline: the detail surface's +// Deadline field. +func (h *TaskHandler) SetDeadline(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + id, ok := parseInt64Param(w, r, "id") + if !ok { + return + } + + req, ok := decodeJSON[setTaskDeadlineRequest](w, r) + if !ok { + return + } + + task, err := h.tasks.SetDeadline(r.Context(), userID, workspaceID, id, req.Due) + if respondError(w, err, taskErrors, "failed to set task deadline") { + return + } + + httpresponse.JSON(w, http.StatusOK, toTaskResponse(task)) +} + +// ClearDeadline serves DELETE /api/tasks/{id}/deadline: the detail surface's +// Deadline field, cleared. +func (h *TaskHandler) ClearDeadline(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + id, ok := parseInt64Param(w, r, "id") + if !ok { + return + } + + task, err := h.tasks.ClearDeadline(r.Context(), userID, workspaceID, id) + if respondError(w, err, taskErrors, "failed to clear task deadline") { + return + } + + httpresponse.JSON(w, http.StatusOK, toTaskResponse(task)) +} + +type updateTaskPriorityRequest struct { + Priority int `json:"priority"` +} + +// UpdatePriority serves PATCH /api/tasks/{id}/priority: the raw 0-9 VTODO +// PRIORITY value (ADR-0083) — None/Low/Medium/High is a presentation +// mapping the caller applies, not a value this endpoint accepts. +func (h *TaskHandler) UpdatePriority(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + id, ok := parseInt64Param(w, r, "id") + if !ok { + return + } + + req, ok := decodeJSON[updateTaskPriorityRequest](w, r) + if !ok { + return + } + + task, err := h.tasks.UpdatePriority(r.Context(), userID, workspaceID, id, req.Priority) + if respondError(w, err, taskErrors, "failed to update task priority") { + return + } + + httpresponse.JSON(w, http.StatusOK, toTaskResponse(task)) +} + +type moveTaskRequest struct { + TaskListID int64 `json:"taskListId"` +} + +// Move serves PUT /api/tasks/{id}/task-list: moves a Task into another Task +// List, refusing (404) a TaskListID that isn't the caller's own. +func (h *TaskHandler) Move(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + id, ok := parseInt64Param(w, r, "id") + if !ok { + return + } + + req, ok := decodeJSON[moveTaskRequest](w, r) + if !ok { + return + } + + task, err := h.tasks.Move(r.Context(), userID, workspaceID, id, req.TaskListID) + if respondError(w, err, taskErrors, "failed to move task") { + return + } + + httpresponse.JSON(w, http.StatusOK, toTaskResponse(task)) +} + // Delete removes a Task outright. func (h *TaskHandler) Delete(w http.ResponseWriter, r *http.Request) { userID := httpauth.MustUserID(r.Context()) diff --git a/server/internal/handlers/task_test.go b/server/internal/handlers/task_test.go index b569a69..3a1edeb 100644 --- a/server/internal/handlers/task_test.go +++ b/server/internal/handlers/task_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strconv" "testing" + "time" "github.com/go-chi/chi/v5" @@ -66,6 +67,11 @@ func newTaskHandlerTestServer(t *testing.T) *taskHandlerTestServer { r.Get("/completed", taskHandler.ListCompleted) r.Post("/", taskHandler.Create) r.Patch("/{id}", taskHandler.Update) + r.Patch("/{id}/notes", taskHandler.UpdateNotes) + r.Put("/{id}/deadline", taskHandler.SetDeadline) + r.Delete("/{id}/deadline", taskHandler.ClearDeadline) + r.Patch("/{id}/priority", taskHandler.UpdatePriority) + r.Put("/{id}/task-list", taskHandler.Move) r.Put("/{id}/complete", taskHandler.Complete) r.Delete("/{id}/complete", taskHandler.Uncomplete) r.Delete("/{id}", taskHandler.Delete) @@ -462,3 +468,138 @@ func TestTaskHandler_DeletingTaskListReparentsItsTasksToTheDefault(t *testing.T) t.Fatalf("expected the task to be reparented to the default task list %d, got %d", inboxID, tasks[0].TaskListID) } } + +// TestTaskHandler_UpdateNotes covers #311: the detail surface's Notes field. +func TestTaskHandler_UpdateNotes(t *testing.T) { + s := newTaskHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + inboxID := s.defaultTaskListID(t, token, workspaceID) + task := s.createTask(t, token, workspaceID, inboxID, "File taxes") + + resp := s.do(t, http.MethodPatch, "/api/tasks/"+strconv.FormatInt(task.ID, 10)+"/notes", token, workspaceID, updateTaskNotesRequest{Notes: "2%, not whole"}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 updating notes, got %d", resp.StatusCode) + } + var updated taskResponse + if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil { + t.Fatalf("decode update notes response: %v", err) + } + if updated.Notes != "2%, not whole" { + t.Fatalf("expected notes %q, got %q", "2%, not whole", updated.Notes) + } +} + +// TestTaskHandler_SetAndClearDeadline covers #311: the detail surface's +// Deadline field, set then cleared, independent of the Time block. +func TestTaskHandler_SetAndClearDeadline(t *testing.T) { + s := newTaskHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + inboxID := s.defaultTaskListID(t, token, workspaceID) + task := s.createTask(t, token, workspaceID, inboxID, "File taxes") + + due := time.Date(2026, time.September, 15, 0, 0, 0, 0, time.UTC) + setResp := s.do(t, http.MethodPut, "/api/tasks/"+strconv.FormatInt(task.ID, 10)+"/deadline", token, workspaceID, setTaskDeadlineRequest{Due: due}) + defer setResp.Body.Close() + if setResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 setting deadline, got %d", setResp.StatusCode) + } + var withDeadline taskResponse + if err := json.NewDecoder(setResp.Body).Decode(&withDeadline); err != nil { + t.Fatalf("decode set deadline response: %v", err) + } + if withDeadline.Due == nil || !withDeadline.Due.Equal(due) { + t.Fatalf("expected due %v, got %v", due, withDeadline.Due) + } + + clearResp := s.do(t, http.MethodDelete, "/api/tasks/"+strconv.FormatInt(task.ID, 10)+"/deadline", token, workspaceID, nil) + defer clearResp.Body.Close() + if clearResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 clearing deadline, got %d", clearResp.StatusCode) + } + var cleared taskResponse + if err := json.NewDecoder(clearResp.Body).Decode(&cleared); err != nil { + t.Fatalf("decode clear deadline response: %v", err) + } + if cleared.Due != nil { + t.Fatalf("expected due to be cleared, got %v", cleared.Due) + } +} + +// TestTaskHandler_UpdatePriority_RoundTripsRawValue covers #311: Priority +// stores VTODO's raw 0-9 value, not an app-specific enum (ADR-0083) — an +// arbitrary in-range value survives untranslated. +func TestTaskHandler_UpdatePriority_RoundTripsRawValue(t *testing.T) { + s := newTaskHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + inboxID := s.defaultTaskListID(t, token, workspaceID) + task := s.createTask(t, token, workspaceID, inboxID, "File taxes") + + resp := s.do(t, http.MethodPatch, "/api/tasks/"+strconv.FormatInt(task.ID, 10)+"/priority", token, workspaceID, updateTaskPriorityRequest{Priority: 3}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 updating priority, got %d", resp.StatusCode) + } + var updated taskResponse + if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil { + t.Fatalf("decode update priority response: %v", err) + } + if updated.Priority != 3 { + t.Fatalf("expected the raw priority value 3 to round-trip untranslated, got %d", updated.Priority) + } +} + +// TestTaskHandler_UpdatePriority_RejectsOutOfRange covers #311's 0-9 bound. +func TestTaskHandler_UpdatePriority_RejectsOutOfRange(t *testing.T) { + s := newTaskHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + inboxID := s.defaultTaskListID(t, token, workspaceID) + task := s.createTask(t, token, workspaceID, inboxID, "File taxes") + + resp := s.do(t, http.MethodPatch, "/api/tasks/"+strconv.FormatInt(task.ID, 10)+"/priority", token, workspaceID, updateTaskPriorityRequest{Priority: 10}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for an out-of-range priority, got %d", resp.StatusCode) + } +} + +// TestTaskHandler_Move covers #311: moving a Task between Task Lists from +// the detail surface. +func TestTaskHandler_Move(t *testing.T) { + s := newTaskHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + inboxID := s.defaultTaskListID(t, token, workspaceID) + work := s.createTaskList(t, token, workspaceID, "Work", "#12809CFF") + task := s.createTask(t, token, workspaceID, inboxID, "File taxes") + + resp := s.do(t, http.MethodPut, "/api/tasks/"+strconv.FormatInt(task.ID, 10)+"/task-list", token, workspaceID, moveTaskRequest{TaskListID: work.ID}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 moving task, got %d", resp.StatusCode) + } + var moved taskResponse + if err := json.NewDecoder(resp.Body).Decode(&moved); err != nil { + t.Fatalf("decode move response: %v", err) + } + if moved.TaskListID != work.ID { + t.Fatalf("expected task list id %d, got %d", work.ID, moved.TaskListID) + } +} + +// TestTaskHandler_Move_RejectsAnotherUsersTaskList mirrors Create's own +// ownership check (#311): naming a Task List id that isn't the caller's own +// must 404, same as a Task that doesn't exist. +func TestTaskHandler_Move_RejectsAnotherUsersTaskList(t *testing.T) { + s := newTaskHandlerTestServer(t) + aliceToken, _, aliceWorkspaceID := s.register(t, "alice") + bobToken, _, bobWorkspaceID := s.register(t, "bob") + aliceInboxID := s.defaultTaskListID(t, aliceToken, aliceWorkspaceID) + bobInboxID := s.defaultTaskListID(t, bobToken, bobWorkspaceID) + task := s.createTask(t, aliceToken, aliceWorkspaceID, aliceInboxID, "File taxes") + + resp := s.do(t, http.MethodPut, "/api/tasks/"+strconv.FormatInt(task.ID, 10)+"/task-list", aliceToken, aliceWorkspaceID, moveTaskRequest{TaskListID: bobInboxID}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 moving into another user's task list, got %d", resp.StatusCode) + } +} diff --git a/server/internal/repository/task.go b/server/internal/repository/task.go index 3f726e2..9472066 100644 --- a/server/internal/repository/task.go +++ b/server/internal/repository/task.go @@ -168,6 +168,88 @@ func (r *TaskRepository) Uncomplete(ctx context.Context, id, userID, workspaceID return r.GetByID(ctx, id, userID, workspaceID) } +// UpdateNotes changes id's notes, scoped to userID and workspaceID like +// GetByID. An empty string clears them. +func (r *TaskRepository) UpdateNotes(ctx context.Context, id, userID, workspaceID int64, notes string) (Task, error) { + res, err := r.db.ExecContext(ctx, + `UPDATE tasks SET notes = ? WHERE id = ? AND user_id = ? AND workspace_id = ?`, + notes, id, userID, workspaceID, + ) + if err != nil { + return Task{}, fmt.Errorf("update task notes: %w", err) + } + if err := requireAffected(res); err != nil { + return Task{}, err + } + return r.GetByID(ctx, id, userID, workspaceID) +} + +// SetDue sets id's Deadline, scoped to userID and workspaceID like GetByID — +// the Time block (start/duration_minutes) is untouched, the two axes being +// independent (ADR-0083). +func (r *TaskRepository) SetDue(ctx context.Context, id, userID, workspaceID int64, due time.Time) (Task, error) { + res, err := r.db.ExecContext(ctx, + `UPDATE tasks SET due = ? WHERE id = ? AND user_id = ? AND workspace_id = ?`, + due, id, userID, workspaceID, + ) + if err != nil { + return Task{}, fmt.Errorf("set task due: %w", err) + } + if err := requireAffected(res); err != nil { + return Task{}, err + } + return r.GetByID(ctx, id, userID, workspaceID) +} + +// ClearDue clears id's Deadline, scoped to userID and workspaceID like +// GetByID. +func (r *TaskRepository) ClearDue(ctx context.Context, id, userID, workspaceID int64) (Task, error) { + res, err := r.db.ExecContext(ctx, + `UPDATE tasks SET due = NULL WHERE id = ? AND user_id = ? AND workspace_id = ?`, + id, userID, workspaceID, + ) + if err != nil { + return Task{}, fmt.Errorf("clear task due: %w", err) + } + if err := requireAffected(res); err != nil { + return Task{}, err + } + return r.GetByID(ctx, id, userID, workspaceID) +} + +// UpdatePriority changes id's raw PRIORITY value (0-9), scoped to userID and +// workspaceID like GetByID. Range validation is the service's job. +func (r *TaskRepository) UpdatePriority(ctx context.Context, id, userID, workspaceID int64, priority int) (Task, error) { + res, err := r.db.ExecContext(ctx, + `UPDATE tasks SET priority = ? WHERE id = ? AND user_id = ? AND workspace_id = ?`, + priority, id, userID, workspaceID, + ) + if err != nil { + return Task{}, fmt.Errorf("update task priority: %w", err) + } + if err := requireAffected(res); err != nil { + return Task{}, err + } + return r.GetByID(ctx, id, userID, workspaceID) +} + +// Move reparents id onto taskListID, scoped to userID and workspaceID like +// GetByID. Whether taskListID actually belongs to the caller is the +// service's job (mirroring Create) — this only guards the Task itself. +func (r *TaskRepository) Move(ctx context.Context, id, userID, workspaceID, taskListID int64) (Task, error) { + res, err := r.db.ExecContext(ctx, + `UPDATE tasks SET task_list_id = ? WHERE id = ? AND user_id = ? AND workspace_id = ?`, + taskListID, id, userID, workspaceID, + ) + if err != nil { + return Task{}, fmt.Errorf("move task: %w", err) + } + if err := requireAffected(res); err != nil { + return Task{}, err + } + return r.GetByID(ctx, id, userID, workspaceID) +} + // Delete removes id outright, scoped to userID and workspaceID like GetByID. func (r *TaskRepository) Delete(ctx context.Context, id, userID, workspaceID int64) error { res, err := r.db.ExecContext(ctx, diff --git a/server/internal/router/router.go b/server/internal/router/router.go index 5f6f92f..fb877e8 100644 --- a/server/internal/router/router.go +++ b/server/internal/router/router.go @@ -301,6 +301,11 @@ func New(logger *slog.Logger, authHandler *handlers.AuthHandler, calendarHandler r.Get("/completed", taskHandler.ListCompleted) r.Post("/", taskHandler.Create) r.Patch("/{id}", taskHandler.Update) + r.Patch("/{id}/notes", taskHandler.UpdateNotes) + r.Put("/{id}/deadline", taskHandler.SetDeadline) + r.Delete("/{id}/deadline", taskHandler.ClearDeadline) + r.Patch("/{id}/priority", taskHandler.UpdatePriority) + r.Put("/{id}/task-list", taskHandler.Move) r.Put("/{id}/complete", taskHandler.Complete) r.Delete("/{id}/complete", taskHandler.Uncomplete) r.Delete("/{id}", taskHandler.Delete) diff --git a/server/internal/service/task.go b/server/internal/service/task.go index 671260a..e7198dc 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/XiovV/calich/server/internal/repository" ) @@ -17,6 +18,11 @@ const completedTaskTail = 50 // title is empty. var ErrInvalidTaskTitle = errors.New("task title must not be empty") +// ErrInvalidTaskPriority is returned by TaskService.UpdatePriority when +// priority falls outside VTODO's PRIORITY range (ADR-0083, #311): 0-9, where +// 0 is None. +var ErrInvalidTaskPriority = errors.New("task priority must be between 0 and 9") + // TaskService creates, lists, updates, completes/uncompletes and deletes // Tasks (#310, ADR-0083). Private outright, like TaskListService — every // method is scoped to the caller's own userID and workspaceID by the @@ -107,6 +113,67 @@ func (s *TaskService) Uncomplete(ctx context.Context, userID, workspaceID, id in return task, nil } +// UpdateNotes changes id's notes, scoped to userID and workspaceID. +func (s *TaskService) UpdateNotes(ctx context.Context, userID, workspaceID, id int64, notes string) (repository.Task, error) { + task, err := s.tasks.UpdateNotes(ctx, id, userID, workspaceID, notes) + if err != nil { + return repository.Task{}, fmt.Errorf("update task notes: %w", err) + } + return task, nil +} + +// SetDeadline sets id's Deadline, scoped to userID and workspaceID — the +// detail surface's Deadline field, independent of the Time block +// (ADR-0083): never touches start/duration_minutes. +func (s *TaskService) SetDeadline(ctx context.Context, userID, workspaceID, id int64, due time.Time) (repository.Task, error) { + task, err := s.tasks.SetDue(ctx, id, userID, workspaceID, due) + if err != nil { + return repository.Task{}, fmt.Errorf("set task deadline: %w", err) + } + return task, nil +} + +// ClearDeadline clears id's Deadline, scoped to userID and workspaceID. +func (s *TaskService) ClearDeadline(ctx context.Context, userID, workspaceID, id int64) (repository.Task, error) { + task, err := s.tasks.ClearDue(ctx, id, userID, workspaceID) + if err != nil { + return repository.Task{}, fmt.Errorf("clear task deadline: %w", err) + } + return task, nil +} + +// UpdatePriority changes id's raw PRIORITY value, scoped to userID and +// workspaceID. Stores the raw 0-9 value rather than an app-specific enum +// (ADR-0083) — None/Low/Medium/High is a presentation mapping the caller +// applies, not a value this accepts. +func (s *TaskService) UpdatePriority(ctx context.Context, userID, workspaceID, id int64, priority int) (repository.Task, error) { + if priority < 0 || priority > 9 { + return repository.Task{}, ErrInvalidTaskPriority + } + + task, err := s.tasks.UpdatePriority(ctx, id, userID, workspaceID, priority) + if err != nil { + return repository.Task{}, fmt.Errorf("update task priority: %w", err) + } + return task, nil +} + +// Move reparents id onto taskListID, scoped to userID and workspaceID. +// taskListID must already be the caller's own — the same ownership check +// Create runs, so a caller can't move a Task into someone else's Task List +// by naming a different id. +func (s *TaskService) Move(ctx context.Context, userID, workspaceID, id, taskListID int64) (repository.Task, error) { + if _, err := s.taskLists.GetByID(ctx, taskListID, userID, workspaceID); err != nil { + return repository.Task{}, fmt.Errorf("get task list: %w", err) + } + + task, err := s.tasks.Move(ctx, id, userID, workspaceID, taskListID) + if err != nil { + return repository.Task{}, fmt.Errorf("move task: %w", err) + } + return task, nil +} + // Delete removes id outright, scoped to userID and workspaceID. func (s *TaskService) Delete(ctx context.Context, userID, workspaceID, id int64) error { if err := s.tasks.Delete(ctx, id, userID, workspaceID); err != nil { diff --git a/src/components/layout/DeleteTaskConfirmation.tsx b/src/components/layout/DeleteTaskConfirmation.tsx new file mode 100644 index 0000000..638a304 --- /dev/null +++ b/src/components/layout/DeleteTaskConfirmation.tsx @@ -0,0 +1,53 @@ +import { AlertDialog } from "@base-ui/react/alert-dialog"; +import type { Task } from "../../lib/tasksApi"; +import { Button } from "../ui/Button"; +import { buttonClasses } from "../ui/buttonClasses"; + +interface DeleteTaskConfirmationProps { + task: Task; + onConfirm: () => void; + onClose: () => void; +} + +// DeleteTaskConfirmation is the detail surface's delete action (#311), +// mirroring DeleteEventConfirmation — a Task is outright removed, with no +// undo, so it gets the same one-question AlertDialog rather than the +// no-confirmation posture Completion uses (ADR-0068 is about a dialog's +// footer, not about whether a destructive action gets asked about at all). +export function DeleteTaskConfirmation({ task, onConfirm, onClose }: DeleteTaskConfirmationProps) { + return ( + { + if (!open) onClose(); + }} + > + + + + + Delete "{task.title}"? + + + This can't be undone. + + +
    + + Cancel + + +
    +
    +
    +
    + ); +} diff --git a/src/components/layout/TaskDetailModal.tsx b/src/components/layout/TaskDetailModal.tsx new file mode 100644 index 0000000..65c19e2 --- /dev/null +++ b/src/components/layout/TaskDetailModal.tsx @@ -0,0 +1,192 @@ +import { useState } from "react"; +import { Dialog } from "@base-ui/react/dialog"; +import { Trash2, X } from "lucide-react"; +import { dateInputValueToDate, dateToDateInputValue } from "../../lib/eventTimeRange"; +import { + PRIORITY_LEVELS, + PRIORITY_LEVEL_LABELS, + priorityLevelFromValue, + priorityValueForLevel, + type PriorityLevel, +} from "../../lib/taskPriority"; +import { useTaskListsStore } from "../../lib/taskListsStore"; +import { useTasksStore } from "../../lib/tasksStore"; +import type { Task } from "../../lib/tasksApi"; +import { toast } from "../../lib/toast"; +import { DeleteTaskConfirmation } from "./DeleteTaskConfirmation"; +import { buttonClasses } from "../ui/buttonClasses"; +import { IconButton } from "../ui/IconButton"; +import { Input } from "../ui/Input"; +import { Select } from "../ui/Select"; +import { Textarea } from "../ui/Textarea"; + +interface TaskDetailModalProps { + task: Task; + onClose: () => void; +} + +// TaskDetailModal is the small, quick detail surface a Task row opens into +// (#311, ADR-0083) — deliberately not EventModal, which carries progressive +// disclosure (ADR-0056), the Save plan (ADR-0066), scoped series edits, +// Attendees, Attachments, Reminders and recurrence, none of which a Task +// has. Every field here commits on its own as the User interacts with it — +// there is no Save, only `Done` (ADR-0068) — because by the time the +// footer is visible, every write above it has already happened. +export function TaskDetailModal({ task, onClose }: TaskDetailModalProps) { + const taskLists = useTaskListsStore((state) => state.taskLists); + const updateTaskNotes = useTasksStore((state) => state.updateTaskNotes); + const setTaskDeadline = useTasksStore((state) => state.setTaskDeadline); + const clearTaskDeadline = useTasksStore((state) => state.clearTaskDeadline); + const updateTaskPriority = useTasksStore((state) => state.updateTaskPriority); + const moveTask = useTasksStore((state) => state.moveTask); + const deleteTask = useTasksStore((state) => state.deleteTask); + + // Seeded once from `task` and never re-synced from it here — TasksPanel + // remounts this component (via `key={task.id}`) whenever a different Task + // opens, which is what actually guards against carrying one Task's + // half-typed notes into another's, without a setState-in-effect. + const [notes, setNotes] = useState(task.notes); + const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); + + function commitNotes() { + if (notes === task.notes) return; + + updateTaskNotes(task.id, notes).catch(() => { + toast.error("Couldn't save the notes."); + setNotes(task.notes); + }); + } + + function handleDeadlineChange(value: string) { + if (!value) return; + setTaskDeadline(task.id, dateInputValueToDate(value)).catch(() => + toast.error("Couldn't set the deadline."), + ); + } + + function handleClearDeadline() { + clearTaskDeadline(task.id).catch(() => toast.error("Couldn't clear the deadline.")); + } + + function handlePriorityChange(level: PriorityLevel) { + updateTaskPriority(task.id, priorityValueForLevel(level)).catch(() => + toast.error("Couldn't update the priority."), + ); + } + + function handleTaskListChange(value: string) { + const taskListId = Number(value); + if (taskListId === task.taskListId) return; + + moveTask(task.id, taskListId).catch(() => toast.error("Couldn't move the task.")); + } + + async function handleConfirmDelete() { + setIsDeleteConfirmOpen(false); + try { + await deleteTask(task.id); + onClose(); + } catch { + toast.error("Couldn't delete the task."); + } + } + + return ( + <> + { + if (!open) onClose(); + }} + > + + + +
    + + {task.title} + + setIsDeleteConfirmOpen(true)} + > + + +
    + +