diff --git a/CONTEXT.md b/CONTEXT.md index 2211771..6a3595e 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**: @@ -37,7 +37,7 @@ The single optional link on an Event pointing at more information about it — a _Avoid_: link, event link, web link, conference link (that is a different thing this app does not have), attachment URL **Event**: -The stored, saved unit — a titled time block belonging to a single Calendar, with a start, an end, an optional Recurrence rule, and an optional Event color. Carries no Reminders of its own: those belong to each User who can see it, not to it. Its start/end define the first occurrence and the duration every Occurrence inherits. A non-recurring Event is a series of one. Distinct from the Occurrences it produces on the grid. +The stored, saved unit — a titled time block belonging to a single Calendar, with a start, an end, a Busy value, an optional Recurrence rule, and an optional Event color. Carries no Reminders of its own: those belong to each User who can see it, not to it. Its start/end define the first occurrence and the duration every Occurrence inherits. A non-recurring Event is a series of one. Distinct from the Occurrences it produces on the grid. _Avoid_: appointment, meeting, entry **Recurrence rule**: @@ -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**: @@ -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**: @@ -164,7 +164,7 @@ Whether times are rendered as 12-hour or 24-hour, applied wherever this app form _Avoid_: clock format, hour format, locale (this is one axis of a locale, not a locale) **Working hours**: -The daily time range (minute-of-day precision) a User treats as their working day, shading the time outside it in Day and Week view. A visual hint only: every hour of the day stays rendered, live, and clickable, and no Occurrence is ever hidden, clipped, or scrolled out of reach by it. Absent by default, in which case nothing is shaded. Carries no availability meaning — it does not affect free/busy, scheduling, or Reminders. See ADR-0039. +The daily time range (minute-of-day precision) a User treats as their working day, shading the time outside it in Day and Week view. A visual hint only: every hour of the day stays rendered, live, and clickable, and no Occurrence is ever hidden, clipped, or scrolled out of reach by it. Absent by default, in which case nothing is shaded. Carries no availability meaning — it does not affect free/busy, scheduling, or Reminders. Still carries none now that Booking Links exist: an Availability Schedule is seeded from it once, at that User's first one, and is a separate object from that moment on (ADR-0085). See ADR-0039. _Avoid_: business hours, office hours, availability, day bounds ## Authentication @@ -244,7 +244,7 @@ The User who created an Event — its iCalendar `ORGANIZER` counterpart to `ATTE _Avoid_: creator, owner (that's the Calendar's), host, chair **Attendee**: -A party invited to one specific Event, independent of any Calendar Access — the invite itself is the visibility grant, scoped to that Event alone. Identified either by a User or, when no account here matches, by a bare email address. There is deliberately **no separate term for the second kind**: the Invitation, the Response set and the `ATTENDEE` line are identical either way, and all that differs is whether this instance happens to hold an account for them. An address is resolved against the Members of the Event's own Workspace when the invite is written, and an outstanding email-shaped Attendee converts to its User if that address later joins the Workspace — a fallback we wrote because we couldn't find their account has no reason to outlive the account appearing. May be invited individually, by typing an address, or via a Group (expanded to its current members at invite time into individual Attendee rows, since a response has to survive later Group membership changes rather than tracking membership dynamically the way a Group Share does); a Group never contains an outside address. Only a User-backed Attendee receives Notifications and may set Reminders here — an emailed one is reminded by their own calendar client, which holds the Invitation. Still not mirrored onto a Linked Calendar, now because a Refresh-written Attendee must be provably incapable of generating an Invitation and that guarantee isn't built. See ADR-0046, ADR-0052, ADR-0058, ADR-0059. +A party invited to one specific Event, independent of any Calendar Access — the invite itself is the visibility grant, scoped to that Event alone. Identified either by a User or, when no account here matches, by a bare email address. There is deliberately **no separate term for the second kind**: the Invitation, the Response set and the `ATTENDEE` line are identical either way, and all that differs is whether this instance happens to hold an account for them. An address is resolved against the Members of the Event's own Workspace when the invite is written, and an outstanding email-shaped Attendee converts to its User if that address later joins the Workspace — a fallback we wrote because we couldn't find their account has no reason to outlive the account appearing. May be invited individually, by typing an address, or via a Group (expanded to its current members at invite time into individual Attendee rows, since a response has to survive later Group membership changes rather than tracking membership dynamically the way a Group Share does); a Group never contains an outside address. Only a User-backed Attendee receives Notifications and may set Reminders here — an emailed one is reminded by their own calendar client, which holds the Invitation. Still not mirrored onto a Linked Calendar, now because a Refresh-written Attendee must be provably incapable of generating an Invitation and that guarantee isn't built. A visitor who books a Booking Link becomes one of the email-shaped kind, with no account here and no Access to anything (ADR-0087). See ADR-0046, ADR-0052, ADR-0058, ADR-0059. _Avoid_: invitee, guest, external attendee (as a term — say "an Attendee who isn't a Member" in prose), participant **Response**: @@ -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**: @@ -323,6 +327,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 @@ -339,6 +347,90 @@ _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 + +## 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). The one place a Task reaches a stranger: while it is incomplete and its owner's Booking Link has Tasks in its Conflict set, a Time block removes that window from the link's slots exactly as a Busy Event does, revealing when its owner is unavailable but never what the Task is (ADR-0087). 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. + +## Booking + +**Booking Link**: +A published way to be booked — one named, slug-addressed offer belonging to one User within one Workspace, carrying a Duration, a Visibility, an Availability Schedule, a Conflict set, a Book-into Calendar, a Location, a Description, a minimum notice and a booking horizon. The only stored entity in this section: everything a visitor sees is derived from one, and nothing else in the feature has a row of its own. Scoped `(User, Workspace)` on the same terms as a Task List, so it appears in the sidebar only while that Workspace is active and dies with the membership that gave it a Calendar to write into. Its Duration is also its slot increment — slots tile from the start of each of its Schedule's ranges, and there is deliberately no second increment to configure. See ADR-0084, ADR-0087. +_Avoid_: booking type, event type (Calendly's word, and it is not an Event), meeting type, booking page (that is the derived index), link (too general) + +**Handle**: +The public name one User is addressed by — unique instance-wide and case-insensitive on the same terms as Email, but unlike Email safe to show a stranger, which is the whole reason it exists. Absent until that User creates their first Booking Link, at which point they claim one, suggested from their Email's local part. Occupies the URL root: `/:handle` is that User's index and `/:handle/:slug` is one Booking Link, so a Handle may not be any word the app already routes on. Changing it breaks every URL published under the old one, deliberately and with nothing forwarding. See ADR-0084. +_Avoid_: username (it authenticates nothing), slug (that is the Booking Link's), vanity URL, profile name + +**Slug**: +The path segment naming one Booking Link under its owner's Handle, unique per User rather than instance-wide, so two people may both publish `intro-call`. Editable after publishing, carrying the same consequence a Handle change does: the old URL dies and nothing forwards, because keeping it alive means keeping a history of every slug either ever had. See ADR-0084. +_Avoid_: path, permalink, alias, handle (that is the User's) + +**Visibility**: +Whether and how a Booking Link can be reached — **Public** (listed on its owner's index and bookable), **Private** (absent from the index but bookable by anyone holding the URL), or **Paused** (absent from the index and refusing bookings, its URL saying so). One field of three values rather than a listed flag beside an enabled flag, because listed-but-refusing is a state nobody wants and would have to be explained every time it rendered. Answers a different question from Access, which governs the host's own Calendars and has no bearing on a stranger. A link behaves as Paused whatever its Visibility says when this instance has no SMTP configured, or when its owner has lost Owner or Editor Access to its Book-into Calendar. See ADR-0087. +_Avoid_: status, state, published, privacy, listed + +**Availability Schedule**: +A named weekly pattern of time ranges belonging to one User, carrying its own IANA timezone, from which a Booking Link's slots are derived before anything is subtracted from them. Reusable — several Booking Links may share one. Its zone is an Anchor zone in everything but name: "9–17 Europe/Sarajevo" means those hours across a DST change and does not move when its owner opens a laptop in another country. Exactly one is auto-created per User, named **Default** and seeded once from that User's Working hours Preference; the two are separate objects from that moment on, and editing the Preference never reaches the Schedule. Deliberately not the Preference itself, which stays a visual hint carrying no availability meaning. See ADR-0085. +_Avoid_: working hours (that is the Preference, and confusing the two is the mistake this term exists to prevent), availability, hours, schedule (too general) + +**Busy**: +Whether an Event consumes the time it occupies — **Busy** or **Free**, held as iCalendar's `TRANSP` (`OPAQUE` / `TRANSPARENT`) so it round-trips CalDAV and a Provider unchanged. Only a Busy Event removes a slot from a Booking Link. A timed Event defaults to Busy, matching RFC 5545; an **all-day Event defaults to Free**, deliberately departing from it, because holidays, birthdays and "launch week" are all all-day, and one subscribed holiday feed would otherwise close whole days its User never considered. Set on a Master and independently on an Override, so one Occurrence of a series may differ from the rest without becoming an Exception. Lives behind More options in the Event modal (ADR-0056). Means nothing for a Task, which has no Calendar and is never shown to a stranger. See ADR-0086. +_Avoid_: transparency, free/busy (that is the derivation, not this field), availability, opaque, blocking + +**Conflict set**: +The Calendars one Booking Link consults when deriving its availability, plus a single row standing for its owner's Tasks. Stored per Booking Link and seeded from every Calendar that User owns in that Workspace, with the Book-into Calendar permanently in it and unremovable — a link that did not check what it writes into would double-book on its second visitor. Deliberately independent of Calendar toggle, the Active Calendar Set and Exposure: what a stranger may book must never depend on what the host happens to be looking at today. With its Tasks row checked, an incomplete Task's Time block removes a slot exactly as a Busy Event does; a Deadline never does, and neither does a completed Task. See ADR-0087. +_Avoid_: check set, calendars, sources, busy calendars, filter + +There is deliberately **no term for a Booking Page.** The index at `/:handle` is a rendering of one User's Public Booking Links and nothing else — no row, no title, no description, no settings, nothing to create or delete — computed in exactly the sense an Occurrence and a Task bucket are. It unions across every Workspace its owner belongs to, because a visitor has no Session and therefore no Active Workspace, and because the page is about the person rather than any organization they happen to be in. UI copy may still read "booking page"; that is a label, not a term. + +There is deliberately **no term for a Booking.** A confirmed booking produces an ordinary Event on the Book-into Calendar — Busy, Organizer the host, with the visitor as an email-shaped Attendee (ADR-0058) receiving the ordinary Invitation (ADR-0059). No second entity records it, so cancelling one is deleting an Event and there is nothing to keep reconciled against anything. The visitor cancels through a signed link in their confirmation mail, which deletes that Event, emits the `METHOD:CANCEL` the Attendee machinery already sends, and emails the host — it raises no Notification, the three producers in that entry staying three. See ADR-0087. + +Deliberately unmodelled, on the same terms as recurring Tasks: buffers before and after a booking, daily caps, reschedule (cancel and rebook says the same thing with half the surface), round-robin and group links, custom questions on the booking form, and recurring bookings. A Booking Link also derives against whatever a Linked Calendar's last Refresh brought in, so a conflict created at a Provider minutes ago may go unseen — accepted knowingly rather than putting a Provider's latency and outages on a stranger's critical path. + ## Deployment **Data directory**: 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/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/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..5b54bb4 --- /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 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 + +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/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/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/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. diff --git a/docs/adr/0084-the-public-namespace-is-the-user.md b/docs/adr/0084-the-public-namespace-is-the-user.md new file mode 100644 index 0000000..acbbb9d --- /dev/null +++ b/docs/adr/0084-the-public-namespace-is-the-user.md @@ -0,0 +1,57 @@ +# The public namespace is the User; the private one is the Workspace + +Status: accepted — introduces the first unauthenticated surface in the app beyond the auth pages, and the first identifier a stranger is ever shown; scoped after ADR-0082 and ADR-0083, addressed against ADR-0044's Workspaces + +A Booking Link is stored per `(User, Workspace)` like a Task List, but is published under a per-User **Handle** at the URL root: `/:handle` is that User's index of Public links and `/:handle/:slug` is one link. The index unions across every Workspace its owner belongs to. There is no stored index page. + +## Why a Handle exists at all + +Nothing in the model could address a User publicly. **Email** is unique but is a login credential, and putting it in a URL hands every visitor a valid username for the login form and for CalDAV Basic auth (ADR-0024). **Name** is explicitly not unique, by ADR-0047, so it cannot address anything. A public identifier therefore had to be invented; the only question was what it names. + +It is **nullable and claimed on first use**, suggested from the Email's local part and deduplicated with a numeric suffix. Seeding one for every existing User at migration time was rejected: it hands a public name to people who will never publish anything, squats the namespace with strings nobody chose, and makes the first thing many self-hosters see about the feature an ugly identifier they did not ask for. + +## Why the User and not the Workspace + +The alternative was a Workspace Handle — `/acme/intro-call` — which is what a product selling to teams would build, and which has a real advantage: it makes the page an organizational surface an Admin can govern. + +It was refused because it inverts who the page is about. The mockup's page is Damir's: his name, his avatar, his timezone, his thirty-minute call. A Workspace-addressed link cannot express "book me" without also implying "on behalf of Acme", and a User in two Workspaces then has two unrelated public identities with no page tying them together. It also forces every published link to be workspace-public, which removes the personal-side-project case entirely. + +The hybrid, `/:workspaceHandle/:userHandle/:slug`, is unambiguous and collision-free and was rejected on ergonomics alone: it is the only option where a User cannot say their own booking URL out loud. + +## Why the row is still Workspace-scoped + +A Booking Link writes an Event into a Calendar, and every Calendar belongs to exactly one Workspace (ADR-0045). A row that floated free of Workspaces would have a Book-into Calendar in a Workspace the row itself did not name, and the sidebar — which is workspace-scoped chrome throughout — would have to show links pointing at Calendars not currently visible. + +So the two scopes differ deliberately, and each is right for its own side: + +- **Private side, Workspace-scoped.** The sidebar lists the Active Workspace's links. Book-into is restricted to Calendars in that Workspace. Losing the membership takes the links with it, exactly as it takes the Task Lists. +- **Public side, User-scoped and unioned.** A visitor has no Session and therefore no Active Workspace; there is nothing to scope the page by. `/damir` lists every Public link Damir has anywhere. + +The consequence is explicit and accepted: a stranger may see one page carrying a link into Acme beside a link into a personal Workspace, and cannot tell them apart. Nobody is exposed who did not choose to be — Visibility is per link — and there is no instance-wide authority who could govern it anyway, ADR-0044 having retired that role. + +The rejected third option was a per-User "which Workspace is public" setting. It removes the mixing, and it costs a setting whose failure mode is a User publishing a link that silently never appears. + +## Decision + +- **`handle` on `users`** — nullable, unique, case-insensitive on the same terms as Email (ADR-0047), claimed at first Booking Link creation, editable in Settings → Account. +- **`slug` on booking links, unique per User**, not instance-wide. Two people may both publish `intro-call`. +- **Root routing with a reserved-word list.** `/:handle` and `/:handle/:slug` sit in the same namespace as the app's own routes, so registration refuses `login`, `register`, `settings`, `api`, `book`, `assets`, `static`, `accept-workspace-invite`, and a small future-proofing set (`admin`, `help`, `about`, `new`). Every future top-level route must be added to that list. This is a permanent maintenance tax, taken knowingly in exchange for the URL shape. +- **No redirects on rename.** Changing a Handle or a Slug breaks every URL published under the old one. The UI warns; nothing forwards. Preserving old URLs means a slug-history table that must be kept forever and consulted on every public request, to rescue a link the User themselves chose to move. +- **Booking links are scoped `(user_id, workspace_id)`** and cascade on both, as an append-only migration per ADR-0048. +- **The index page has no row.** It is derived from the owner's Public links, in the sense ADR-0083 derives Task buckets. + +## Considered Options + +- **A per-User Handle at the URL root, with Workspace-scoped rows (chosen).** +- **A Workspace Handle.** Right for a team product; cannot express a personal booking page, and gives a User in two Workspaces two unrelated identities. +- **Both handles in the path.** Collision-proof, and the only option nobody can say aloud. +- **No handle — `/book/:opaqueId` only.** Costs nothing and collides with nothing, but there is then no index worth having, which collapses the Public/Private distinction into nothing. +- **A `/u/` or `/book/` prefix instead of the root.** Structurally immune to route collisions and needs no reserved list. Rejected for the URL shape alone; it remains the cheap escape if the reserved list ever becomes a burden. +- **Seeding a Handle for every User at migration.** Rejected: assigns public names to people who never asked for one. + +## Consequences + +- **The app now has an unauthenticated read surface that touches calendar data.** Every public endpoint must resolve Visibility, Access and the SMTP gate before revealing that a Handle even exists — including returning the same response for "no such handle" and "that link is Private", or the namespace becomes a User enumerator. +- **A reserved-word list is now a standing obligation.** Adding a top-level route without adding it to the list will shadow somebody's published page, and the failure is silent. +- **Losing a Workspace membership silently unpublishes links.** They behave as Paused rather than 404ing, so a stranger holding the URL is told it is not accepting bookings, not that it never existed. +- **Handle changes are unrecoverable by design.** There is no way to answer "who had this handle last week", which is also why a released Handle can be claimed by someone else immediately. diff --git a/docs/adr/0085-availability-schedules-are-not-the-working-hours-preference.md b/docs/adr/0085-availability-schedules-are-not-the-working-hours-preference.md new file mode 100644 index 0000000..5eb5c90 --- /dev/null +++ b/docs/adr/0085-availability-schedules-are-not-the-working-hours-preference.md @@ -0,0 +1,53 @@ +# Availability Schedules are separate from the Working hours Preference + +Status: accepted — preserves the boundary ADR-0039 drew around Preferences; the Working hours entry in CONTEXT.md keeps its "carries no availability meaning" clause unchanged + +A Booking Link derives its slots from an **Availability Schedule**: a named, per-User weekly pattern of time ranges carrying its own IANA timezone. One is auto-created per User, named **Default** and seeded once from that User's Working hours Preference, and the two are separate objects from that moment on. + +## Why not simply promote the Preference + +The Working hours Preference already stores a daily range and already means "my working day", so reusing it is the obvious move. It is also nearly free: two existing columns, plus a weekday mask they currently lack. + +It was refused because it changes what a field is, not just what it does. Today Working hours shades the region outside it in Day and Week view and nothing else — CONTEXT.md says so twice, and ADR-0039 put it among the Preferences on the explicit grounds that "none of them changes what an Event *is*". Promoting it makes a **display preference into a public contract**. A User who nudges the shading because they want to see more of their morning has, without being told, opened themselves to strangers at 07:00. The gesture that changes it is a visual one; the consequence is not. + +The reverse failure is as bad and less obvious: someone who wants their bookable window narrower than their working day cannot have it, because the same number does both jobs. + +There is a structural objection too. Working hours is **one range per day**, identical Monday to Sunday. Real availability is at least a weekly pattern — the mockup itself reads "Mon–Fri, 9–17" — and frequently a split one (09:00–12:00 and 14:00–17:00). Extending the Preference to carry weekdays and multiple ranges per day is no longer a Preference; it is this entity, stored in the wrong place and reachable from the wrong screen. + +## Why the Schedule carries a timezone and the Preference does not + +A Preference renders for one person on one screen, so it can be interpreted in whatever zone that person's browser reports. A Schedule is read by strangers to decide when someone is free, so it needs a fixed anchor for the same reason an Event does (ADR-0019): "9–17" must mean the same hours across a DST change, and must not move when its owner opens a laptop in another country. The zone on a Schedule is an Anchor zone in everything but name. + +Slots are derived in the Schedule's zone and rendered in the visitor's own, detected from their browser and correctable on the page. The visitor's zone is render-time only and is stored nowhere, which is exactly the Viewer zone rule already in the model. + +## Why it is seeded, and why only once + +Seeding costs one insert and makes the feature true on first sight: a User whose Working hours are 09:00–17:00 opens the new-link modal and reads back the hours they already told the app about. Not seeding means the first Booking Link anyone creates is unbookable until they go and configure something else. + +**The link is then cut.** A live link — the Schedule tracking the Preference until touched — is the shadow-tracking pattern from ADR-0032, and it is wrong here for the same reason the promotion was: it leaves a grid-shading control silently wired to a public contract, and it does so in the hardest way to notice, because the wire is invisible until the day someone changes their shading. + +The seeded Schedule is named **Default**, not "Working hours". Two different objects sharing one name in one UI is the confusion this entire ADR exists to prevent, and the User may rename it. + +## Decision + +- **`availability_schedules`**, per User: name, IANA `tzid`, and a set of weekly ranges (weekday, start minute, end minute), several per weekday permitted. Append-only migration per ADR-0048. +- **One auto-created per User named "Default"**, seeded from `working_hours_start` / `working_hours_end` across Mon–Fri when those are set, and 09:00–17:00 Mon–Fri when they are not. Created lazily, at that User's first Booking Link. +- **Seeded once, never synchronised.** Editing the Working hours Preference afterwards has no effect on any Schedule, ever. +- **Reusable.** A Booking Link references a Schedule; several links may share one. Deleting a Schedule still referenced is refused. +- **Working hours is unchanged** — same columns, same meaning, same "visual hint only, carries no availability meaning" clause in CONTEXT.md, same absence of a weekday dimension. +- **Schedules live in Settings**, beside the other per-User state, not inside the Booking Link modal, which picks one from a dropdown. + +## Considered Options + +- **A separate reusable entity with its own timezone, seeded once (chosen).** +- **Promote the Working hours Preference** by adding weekdays. Cheapest, and it turns a display control into a public contract with no gesture marking the change. +- **A live-tracking Schedule** that follows the Preference until touched, after ADR-0032's shadow-tracking. Same hazard as promotion, hidden behind one more indirection. +- **Inline hours on each Booking Link**, with no reusable entity. Fine at one link; at five, changing your hours means five edits and one of them gets missed. +- **Seed nothing.** No confusion, and the first link anyone creates is unbookable until they discover a second screen. + +## Consequences + +- **Two things in the UI now describe a working day**, and a User will eventually change one expecting the other to follow. The naming ("Default", not "Working hours") and their placement in different Settings Sections are the only defences; this is the accepted cost of not wiring them together. +- **Schedules are the second per-User entity carrying a timezone**, after the Event's Anchor zone. DST correctness must be tested against them independently — a range spanning a transition is the case to cover. +- **Deriving availability is a server-side job.** The public page has no Session, so slot derivation runs on the backend, expanding Busy Events with `recurrence.ExpandOccurrences` — which already exists and which the reminder scheduler already uses, so ADR-0016's frontend-expansion position is untouched for the authenticated app. +- **Nothing forces a User to have a sensible Schedule.** An empty one is legal and yields a link with no slots. The UI should say so; the model does not prevent it. diff --git a/docs/adr/0086-busy-is-transp-and-all-day-defaults-to-free.md b/docs/adr/0086-busy-is-transp-and-all-day-defaults-to-free.md new file mode 100644 index 0000000..f412694 --- /dev/null +++ b/docs/adr/0086-busy-is-transp-and-all-day-defaults-to-free.md @@ -0,0 +1,46 @@ +# Busy is TRANSP, and all-day Events default to Free + +Status: accepted — adds the first Event field that exists to be read by someone who is not a User; deliberately departs from RFC 5545's default for all-day Events + +An Event carries **Busy** or **Free**, stored as iCalendar's `TRANSP` (`OPAQUE` / `TRANSPARENT`). Only a Busy Event removes a slot from a Booking Link. A timed Event defaults to **Busy**; an all-day Event defaults to **Free**. + +## Why the field is `TRANSP` and not an invention + +Free/busy transparency is already modelled in iCalendar, already supported by every CalDAV client, and already present on Google's API as `transparency`. Storing anything else means translating at three boundaries — CalDAV out, Google in, Google out — and losing information at each. ADR-0026 accepts lossy round-trips where there is no alternative; here there is one, for free. + +The glossary term is **Busy** rather than `TRANSP`, following the same rule that gave us Anchor zone over `TZID`, Deadline over `DUE` and Completion over `STATUS`: the wire name is not the domain name. + +## Why all-day Events default to Free + +RFC 5545 defaults every `VEVENT` to `OPAQUE`, and the consistent choice is to inherit that for both kinds. This ADR does not, and the departure is the reason it exists. + +Almost every all-day Event in a real calendar is an annotation rather than an occupation. Birthdays, name days, public holidays, "launch week", "Q3 planning", someone else's leave — all all-day, none of them a reason a stranger cannot have thirty minutes. Meanwhile a single **Subscribed Calendar** of public holidays is a handful of clicks and a dozen all-day Events a year, and ADR-0012 and ADR-0032 made such feeds easy on purpose. + +Under the RFC default, subscribing to a holiday feed silently closes twelve whole days a User never thought about, on a public page they cannot see themselves. The damage is invisible from inside the app: the grid looks fine, the link looks fine, and the only symptom is bookings that never arrive. + +Under the chosen default, the failure inverts: a User who takes a day off by creating an all-day "PTO" Event, and never opens **More options**, stays bookable through it. That failure is **visible and self-correcting** — a booking lands in their calendar on the day they are away, they see it, they flip the Event to Busy. The first failure produces silence, which nobody debugs. + +So the rule is: the default is chosen for the common case (annotations, which outnumber occupations heavily), and the uncommon case fails loudly rather than quietly. + +## Decision + +- **`transp` on `events`**, defaulting to `OPAQUE` at the column level, with the **all-day path writing `TRANSPARENT`** on create — so the departure lives in one place and the column keeps the RFC's own default for everything else. +- **Independently set on a Master and on an Override**, since an Override is already a complete standalone instance (ADR-0016). "The Monday standup is Busy, except on the 14th" needs no Exception. +- **Behind More options in the Event modal**, per ADR-0056's progressive disclosure. It is not on the default face; most Events never need it touched. +- **Round-trips in both directions.** The codec emits it (ADR-0031), CalDAV serves it, the Google mapper reads and writes `transparency`, and an imported Event keeps whatever its file said — including an all-day one marked `OPAQUE`, which is honoured rather than re-defaulted. +- **A Task has no Busy.** A Task has no Calendar, never reaches CalDAV or a Provider (ADR-0083), and is never shown to a stranger; whether its Time block closes a slot is answered per Booking Link by the Conflict set, not per Task. + +## Considered Options + +- **`TRANSP`, timed defaults Busy, all-day defaults Free (chosen).** +- **`TRANSP` with the RFC default for both.** One rule, faithful to the spec, and it hands the holiday-feed footgun to every self-hoster who subscribes to one. +- **All-day defaults Free, and Subscribed and Linked Calendars excluded from busy entirely.** Solves the feed problem harder, and breaks the case the feature exists for: a User whose real calendar is Google needs Google conflicts to count. +- **A per-Calendar "count this calendar as busy" flag** instead of a per-Event field. Coarser, does not round-trip, and cannot express "this one meeting is optional" — which is most of what the field is for day to day. +- **No field; every Event blocks.** Ships sooner and makes optional Events, holiday feeds and tentative blocks indistinguishable from real commitments. + +## Consequences + +- **An imported or synced Event may disagree with the default.** An all-day `OPAQUE` Event arriving from a native client is honoured, so two all-day Events on one grid may behave differently with nothing on screen distinguishing them until the modal is opened. This is correct — the remote client meant it — and is the price of lossless round-tripping. +- **Every existing Event becomes Busy on migration**, including existing all-day ones, because backfilling `TRANSPARENT` onto them would silently change the meaning of data a User already entered. Only Events created after this ships get the all-day default. +- **The field is invisible until someone publishes a Booking Link.** Until then it changes nothing anyone can see, which means it will be wrong on a lot of Events by the time it first matters. +- **Free/busy is still not exposed as a CalDAV `VFREEBUSY` report.** The field is now there to support one if it is ever wanted; nothing serves it in this cut. diff --git a/docs/adr/0087-a-booking-is-an-ordinary-event.md b/docs/adr/0087-a-booking-is-an-ordinary-event.md new file mode 100644 index 0000000..26c49d9 --- /dev/null +++ b/docs/adr/0087-a-booking-is-an-ordinary-event.md @@ -0,0 +1,78 @@ +# A booking is an ordinary Event, not its own entity + +Status: accepted — reuses ADR-0058's email-shaped Attendee and ADR-0059's iMIP path wholesale; gates the public surface on SMTP, and deliberately adds no fourth Notification producer + +A confirmed booking creates an ordinary Event on the Booking Link's Book-into Calendar: Busy, Organizer the host, with the visitor as an email-shaped Attendee. Nothing else is stored. There is no `bookings` table. + +## Why there is no booking entity + +The obvious design is a `bookings` row holding the visitor's name, email, answers and a cancel token, pointing at the Event it produced. Every scheduling product has one, and it buys custom questions and a record that outlives the Event. + +It was refused because the two rows would have to be kept true about each other forever, and every question about divergence has a bad answer. The host deletes the Event from the grid — is the booking cancelled? The host drags it to another Calendar or another hour — does the booking still describe reality? The host edits the title? Each of those is a rule to invent and then defend, and the host has no idea a second row exists. + +The decisive point is that **the model already holds everything a booking is**. ADR-0058 established an Attendee identified by a bare email address, for exactly the case where no account here matches. ADR-0059 already emails that address a `METHOD:REQUEST` which a mail client renders as an invite card, already re-sends on material change with an incremented `SEQUENCE`, and already withdraws with `METHOD:CANCEL`. A booking is a person with no account being invited to an Event. That is the existing feature, reached from a different door. + +So: **the Event is the booking.** Deleting it cancels it, because there is nothing else that could have been the truth. + +The cost is accepted and real: custom questions on the booking form have nowhere to live, so they are unmodelled. A booking also leaves no trace once its Event is deleted — there is no history to consult about who booked what last quarter. + +## Why the visitor's Attendee row is not a security hole + +An Attendee is a visibility grant scoped to one Event (ADR-0046), and an email-shaped one has no account, so it resolves no Access and reads nothing. It receives the Invitation and nothing further: no Notifications, no Reminders here, no view of the host's calendar. If that address later joins the Workspace, the existing conversion rule turns the row into their User, which is correct — they are the same person, and they were already on that Event. + +## Why publishing requires SMTP + +SMTP is optional in this app; `app.go` keeps a deliberately nil `InvitationMailer` when none is configured, and Email-Channel Reminders simply do not fire. That degradation is acceptable for a feature a User uses on their own calendar. It is not acceptable here. + +With no SMTP, a stranger fills in a form, sees a success screen, and receives nothing: no invite card, no confirmation, no cancel link, nothing to put in their own calendar, and no way to know it worked. The host gets an Event; the visitor gets a blank. The failure is entirely borne by someone who cannot diagnose it and has no relationship with the instance. + +So **Visibility is clamped to Paused whenever SMTP is absent.** Links may be created and configured; they cannot be reached. The self-hoster is told why in Settings. The rest of the app is untouched. + +The same clamp covers a second case: the host losing Owner or Editor Access to the Book-into Calendar after publishing. A stranger's booking must not be the thing that discovers a revoked Share, so Access is re-checked at booking time, not only at creation. + +## Why cancellation is a signed link and not an inbound decline + +The visitor holds an Invitation, so the apparent answer is to let them decline it in their mail client. That reply is ingested as a Response by the reply poller — **when one is running**, which requires IMAP, which is also optional (`app.go`: "ReplyWorker is nil when no IMAP transport is configured"). On a typical instance the decline reaches nobody. Worse, even where it lands, a Declined Attendee does not free the slot: the Event is still Busy, so a cancelled meeting keeps blocking the calendar until the host notices and deletes it by hand. + +So the confirmation mail carries a **signed cancel link** — an HMAC over the Event id, on a public rate-limited endpoint (ADR-0070). Following it deletes the Event, which emits the `METHOD:CANCEL` the Attendee machinery already sends and frees the slot immediately, and emails the host. Cancelling after the Event has started is refused. + +Reschedule is deliberately not built: it is cancel-then-rebook with twice the public surface and twice the token handling, and the visitor can already do both in two clicks. + +**The host is told by email, not by a Notification.** The Notification entry in CONTEXT.md names exactly three producers and argues at length for that limit; a booking cancellation does not clear that bar, and adding it would start the same erosion ADR-0061 widened once already. + +## Why the slot is re-derived inside the write transaction + +Availability is derived, so nothing reserves anything, and two visitors can validate the same slot against the same read and both submit. ADR-0018 already provides a transaction seam for atomic multi-table writes, so the derivation runs inside it: derive busy, check the slot, insert the Event, commit. The loser is told the time was just taken and shown refreshed slots. + +Holding a slot while the visitor fills in the form was rejected: it is a reservation entity with an expiry, a sweeper, and a habit of leaking held slots whenever someone closes a tab — the second stored entity this ADR exists to avoid. + +## Decision + +- **A booking writes one Event** on the Book-into Calendar: Busy, `title = " — "`, the link's Location and Description copied into `location` and `description`, Anchor zone the Schedule's zone, Organizer the host. +- **The visitor becomes an email-shaped Attendee** and receives the ordinary Invitation (ADR-0059). The booking form asks name and email, both required, and nothing else. +- **No `bookings` table.** No custom questions, no booking history. +- **Publishing requires SMTP.** Absent it, every link behaves as Paused regardless of its Visibility, and Settings says so. +- **Access is re-checked at booking time.** A host without Owner or Editor Access to the Book-into Calendar has a Paused link. +- **Cancellation is a signed HMAC link** in the confirmation mail, rate-limited per ADR-0070, refused after the Event's start. It deletes the Event and emails the host. **No new Notification producer.** +- **The slot is re-derived inside the write transaction** (ADR-0018). No reservations, no holds. +- **A Linked Calendar is a legal Book-into target**, and the Event reaches the Provider through the ordinary Write-back queue (ADR-0075). The visitor is confirmed before the push lands; a permanently failing push raises the existing ADR-0081 Notification on the host. +- **The Conflict set is per Booking Link** — Calendars, plus one row standing for Tasks — seeded from the Calendars that User owns in the Workspace, with Book-into pinned and unremovable. **Calendar toggle, the Active Calendar Set and Exposure never participate.** +- **Slots tile at the link's Duration** from the start of each Schedule range, minus a minimum notice (default 4 hours) and bounded by a booking horizon (default 60 days). + +## Considered Options + +- **The Event is the booking; no second entity (chosen).** +- **A `bookings` table beside the Event.** Buys custom questions and history; costs a second lifecycle with no good answer for any way the two can diverge. +- **The Event plus a minimal token row** for cancellation only. Nearly chosen; an HMAC over the Event id needs no row at all, so the row bought nothing. +- **Inbound declines as the cancellation path.** Reuses the reply poller and requires IMAP, which most instances will not have; and a decline does not free the slot anyway. +- **Slot holds during form entry.** Strictly correct under concurrency, at the cost of exactly the entity and lifecycle this ADR set out to avoid. +- **Refusing Linked Calendars as Book-into targets.** Removes the confirmed-before-pushed gap, and makes the feature useless to anyone whose real calendar is at a Provider. +- **Synchronous push to the Provider before confirming.** Puts a Provider's latency and outages on a stranger's critical path; a Google incident would mean nobody can book. + +## Consequences + +- **A booking's Event is indistinguishable from a hand-created one**, by design. The host can edit, move or delete it like anything else — and moving it does not tell the visitor, beyond the `SEQUENCE`-bumped Invitation ADR-0059 already re-sends. +- **Availability is only as fresh as the last Refresh.** A conflict created at a Provider minutes ago may not be visible, so a booking can collide with a Google event this instance has not pulled. Accepted knowingly; the local Event commits immediately either way, so the app never double-books against itself. +- **The first public write endpoint exists.** It creates rows, sends mail and is reachable without a Session, so rate limiting, HMAC verification and Visibility resolution are load-bearing rather than defensive. +- **An incomplete Task's Time block can now be inferred by a stranger.** With Tasks in the Conflict set, a missing slot reveals that its owner is busy then — never what the Task is, but the window is visible. This is the only path by which a private Task reaches anyone else, and it is opt-out per link. +- **There is no record of a cancelled booking.** The Event is gone, and nothing says it ever existed. Anyone wanting an audit trail needs the `bookings` table this ADR declined. diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index ab22fe9..e7807c1 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.TaskListHandler, a.TaskHandler, a.ConnectionHandler, a.AvailabilityScheduleHandler, a.BookingLinkHandler, a.PublicBookingHandler, 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..abe7626 100644 --- a/server/internal/app/app.go +++ b/server/internal/app/app.go @@ -46,17 +46,23 @@ type App struct { // no Invitation is ever queued. Mailer *mailer.SMTPMailer - 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 + 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 + AvailabilityScheduleHandler *handlers.AvailabilityScheduleHandler + BookingLinkHandler *handlers.BookingLinkHandler + PublicBookingHandler *handlers.PublicBookingHandler CalDAVBackend *caldavserver.Backend CalDAVHandler http.Handler @@ -118,7 +124,13 @@ 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.TaskListHandler = handlers.NewTaskListHandler(a.TaskLists) + a.TaskHandler = handlers.NewTaskHandler(a.Tasks) a.ConnectionHandler = handlers.NewConnectionHandler(a.Connections) + a.AvailabilityScheduleHandler = handlers.NewAvailabilityScheduleHandler(a.AvailabilitySchedules) + a.BookingLinkHandler = handlers.NewBookingLinkHandler(a.BookingLinks) + a.PublicBookingHandler = handlers.NewPublicBookingHandler(a.PublicBookings, a.PublicIndex, a.PublicBookingRateLimiter) a.CalDAVBackend = caldavserver.NewBackend(a.Calendars, a.Events, a.Attachments, cfg.MaxAttachmentSize, cfg.MaxAttachmentsPerEvent) a.CalDAVHandler = caldavserver.NewHTTPHandler(a.CalDAVBackend) diff --git a/server/internal/apptest/apptest.go b/server/internal/apptest/apptest.go index 682f10b..383350a 100644 --- a/server/internal/apptest/apptest.go +++ b/server/internal/apptest/apptest.go @@ -48,6 +48,11 @@ func Config(t *testing.T) config.Config { AuthRateLimitPerEmail: 1000, AuthRateLimitPerIP: 1000, RegisterRateLimitPerIP: 1000, + // Well above defaultPublicBookingRateLimitPerIP (#324, ADR-0087), so + // only a test that lowers it deliberately ever sees the cap — every + // public booking handler test in this repo hits it repeatedly from + // the same loopback address. + PublicBookingRateLimitPerIP: 1000, // No cadence of its own: a test that refreshes a Subscription does so // explicitly rather than waiting for one to come due (ADR-0033). SubscriptionRefreshInterval: 0, diff --git a/server/internal/availability/slots.go b/server/internal/availability/slots.go new file mode 100644 index 0000000..a905476 --- /dev/null +++ b/server/internal/availability/slots.go @@ -0,0 +1,143 @@ +// Package availability derives a Booking Link's bookable slots (#323, +// ADR-0087). DeriveSlots is a pure function — no I/O, no database — that +// mirrors internal/recurrence's own leaf-package shape: the public page +// (#324) will call whatever assembles this package's inputs with no +// Session at all, so the derivation itself must not reach for one either. +package availability + +import ( + "fmt" + "sort" + "time" +) + +// Range is one weekly time range on an Availability Schedule (ADR-0085): a +// weekday (0-6, Sunday-Saturday, matching time.Weekday's own convention) +// plus a start/end minute-of-day pair. Several per weekday are legal, so a +// split day is representable. +type Range struct { + Weekday int + StartMinute int + EndMinute int +} + +// BusyInterval is one closed window of unavailability, as absolute +// instants — already expanded and assembled by the caller from Busy +// Occurrences and incomplete Tasks' Time blocks (ADR-0087). This package +// performs no expansion of its own; it only tests candidate slots against +// the intervals it's handed. +type BusyInterval struct { + Start time.Time + End time.Time +} + +// Params is DeriveSlots' entire input — everything ADR-0087's derivation +// needs, and nothing it fetches itself (#323's "performs no I/O and reads +// no database"). +type Params struct { + // Ranges is the Availability Schedule's weekly pattern. + Ranges []Range + // Tzid is the Schedule's own IANA timezone (its own Anchor zone, + // ADR-0085) — every Range is interpreted in this zone, never the + // caller's, so a range spanning a DST transition yields the wall-clock + // hours stated regardless of where Now happens to be. + Tzid string + // Busy closes slots it overlaps. Free Events, a Deadline, a completed + // Task, and an all-day Event that isn't explicitly Busy must never + // appear here — that filtering is the caller's job (ADR-0087). + Busy []BusyInterval + // DurationMinutes is the Booking Link's own Duration — also its slot + // increment; there is no second increment to configure. + DurationMinutes int + // MinimumNoticeMinutes truncates the near edge: no slot may start + // before Now plus this many minutes. + MinimumNoticeMinutes int + // BookingHorizonDays bounds the far edge: no slot may start on or after + // Now plus this many days. + BookingHorizonDays int + // Now is the current instant derivation is measured from. + Now time.Time +} + +// DeriveSlots returns every bookable slot start for a Booking Link, tiled +// at Params.DurationMinutes from the start of each matching weekly Range, +// ascending. Pure: no I/O, no database (#323). +func DeriveSlots(p Params) ([]time.Time, error) { + if p.DurationMinutes <= 0 { + return nil, fmt.Errorf("duration must be positive, got %d minutes", p.DurationMinutes) + } + + loc, err := time.LoadLocation(p.Tzid) + if err != nil { + return nil, fmt.Errorf("load location %q: %w", p.Tzid, err) + } + + duration := time.Duration(p.DurationMinutes) * time.Minute + earliestAllowed := p.Now.Add(time.Duration(p.MinimumNoticeMinutes) * time.Minute) + // AddDate, not Add(N*24*time.Hour): a calendar-day horizon must land on + // the same wall-clock instant it would without any DST transition + // between Now and it, which only a calendar-aware step guarantees. + horizonCutoff := p.Now.AddDate(0, 0, p.BookingHorizonDays) + + rangesByWeekday := make(map[int][]Range, len(p.Ranges)) + for _, r := range p.Ranges { + rangesByWeekday[r.Weekday] = append(rangesByWeekday[r.Weekday], r) + } + + var slots []time.Time + + nowLocal := p.Now.In(loc) + // Starts at Now's own calendar day, not the next one: a day already + // partway gone can still have later Ranges worth checking — minimum + // notice, not the day boundary, is what actually truncates the near + // edge. + day := time.Date(nowLocal.Year(), nowLocal.Month(), nowLocal.Day(), 0, 0, 0, 0, loc) + for !day.After(horizonCutoff) { + year, month, dom := day.Date() + weekday := int(day.Weekday()) + + for _, r := range rangesByWeekday[weekday] { + // Constructed via time.Date directly from the day's own + // calendar components, never by adding a Duration to another + // instant — Add is a fixed real-world offset, and a range's + // start/end are wall-clock quantities that must resolve + // through the Location's own DST rules (ADR-0085). + rangeStart := time.Date(year, month, dom, r.StartMinute/60, r.StartMinute%60, 0, 0, loc) + rangeEnd := time.Date(year, month, dom, r.EndMinute/60, r.EndMinute%60, 0, 0, loc) + + for t := rangeStart; !t.Add(duration).After(rangeEnd); t = t.Add(duration) { + if t.Before(earliestAllowed) { + continue + } + if !t.Before(horizonCutoff) { + continue + } + if overlapsAny(t, t.Add(duration), p.Busy) { + continue + } + slots = append(slots, t) + } + } + + // AddDate, not Add(24*time.Hour): the same calendar-aware step the + // horizon cutoff above needs, so a DST transition day still steps + // to the following calendar day rather than 23 or 25 hours later. + day = day.AddDate(0, 0, 1) + } + + sort.Slice(slots, func(i, j int) bool { return slots[i].Before(slots[j]) }) + return slots, nil +} + +// overlapsAny reports whether [start, end) overlaps any of busy — a +// half-open comparison on both sides, so two intervals meeting exactly at +// a shared instant (an adjacent Busy interval leaving an exact-Duration +// gap) never count as overlapping. +func overlapsAny(start, end time.Time, busy []BusyInterval) bool { + for _, b := range busy { + if start.Before(b.End) && b.Start.Before(end) { + return true + } + } + return false +} diff --git a/server/internal/availability/slots_test.go b/server/internal/availability/slots_test.go new file mode 100644 index 0000000..5bb89a8 --- /dev/null +++ b/server/internal/availability/slots_test.go @@ -0,0 +1,437 @@ +package availability + +import ( + "testing" + "time" +) + +func mustLoadLocation(t *testing.T, tzid string) *time.Location { + t.Helper() + loc, err := time.LoadLocation(tzid) + if err != nil { + t.Fatalf("load location %q: %v", tzid, err) + } + return loc +} + +// formatAll renders slots as "15:04" in loc, for assertions that only care +// about the wall-clock hour, not the date. +func formatAll(slots []time.Time, loc *time.Location) []string { + out := make([]string, len(slots)) + for i, s := range slots { + out[i] = s.In(loc).Format("15:04") + } + return out +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestDeriveSlots_EmptySchedule(t *testing.T) { + now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + slots, err := DeriveSlots(Params{ + Ranges: nil, + Tzid: "Etc/UTC", + DurationMinutes: 30, + MinimumNoticeMinutes: 0, + BookingHorizonDays: 7, + Now: now, + }) + if err != nil { + t.Fatalf("derive slots: %v", err) + } + if len(slots) != 0 { + t.Fatalf("expected no slots for an empty schedule, got %v", slots) + } +} + +func TestDeriveSlots_OneRange(t *testing.T) { + now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + weekday := int(now.Weekday()) + + slots, err := DeriveSlots(Params{ + Ranges: []Range{{Weekday: weekday, StartMinute: 9 * 60, EndMinute: 17 * 60}}, + Tzid: "Etc/UTC", + DurationMinutes: 30, + MinimumNoticeMinutes: 0, + BookingHorizonDays: 1, + Now: now, + }) + if err != nil { + t.Fatalf("derive slots: %v", err) + } + if len(slots) != 16 { + t.Fatalf("expected 16 half-hour slots across an 8-hour range, got %d: %v", len(slots), slots) + } + if !slots[0].Equal(time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)) { + t.Fatalf("expected the first slot at 09:00, got %v", slots[0]) + } + last := slots[len(slots)-1] + if !last.Equal(time.Date(2026, 6, 1, 16, 30, 0, 0, time.UTC)) { + t.Fatalf("expected the last slot at 16:30, got %v", last) + } +} + +// TestDeriveSlots_SeveralRangesOnAWeekday covers a split day, and that +// results are sorted ascending regardless of the input Ranges' own order. +func TestDeriveSlots_SeveralRangesOnAWeekday(t *testing.T) { + now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + weekday := int(now.Weekday()) + + slots, err := DeriveSlots(Params{ + Ranges: []Range{ + // Afternoon listed first, deliberately, to prove sorting. + {Weekday: weekday, StartMinute: 14 * 60, EndMinute: 17 * 60}, + {Weekday: weekday, StartMinute: 9 * 60, EndMinute: 12 * 60}, + }, + Tzid: "Etc/UTC", + DurationMinutes: 60, + MinimumNoticeMinutes: 0, + BookingHorizonDays: 1, + Now: now, + }) + if err != nil { + t.Fatalf("derive slots: %v", err) + } + got := formatAll(slots, time.UTC) + want := []string{"09:00", "10:00", "11:00", "14:00", "15:00", "16:00"} + if !equalStrings(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } +} + +func TestDeriveSlots_BusyIntervalInsideARange(t *testing.T) { + now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + weekday := int(now.Weekday()) + + slots, err := DeriveSlots(Params{ + Ranges: []Range{{Weekday: weekday, StartMinute: 9 * 60, EndMinute: 17 * 60}}, + Busy: []BusyInterval{ + {Start: time.Date(2026, 6, 1, 11, 0, 0, 0, time.UTC), End: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)}, + }, + Tzid: "Etc/UTC", + DurationMinutes: 60, + MinimumNoticeMinutes: 0, + BookingHorizonDays: 1, + Now: now, + }) + if err != nil { + t.Fatalf("derive slots: %v", err) + } + got := formatAll(slots, time.UTC) + want := []string{"09:00", "10:00", "12:00", "13:00", "14:00", "15:00", "16:00"} + if !equalStrings(got, want) { + t.Fatalf("expected the 11:00 slot alone removed, got %v", got) + } +} + +func TestDeriveSlots_BusyIntervalStraddlingRangeStart(t *testing.T) { + now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + weekday := int(now.Weekday()) + + slots, err := DeriveSlots(Params{ + Ranges: []Range{{Weekday: weekday, StartMinute: 9 * 60, EndMinute: 17 * 60}}, + Busy: []BusyInterval{ + {Start: time.Date(2026, 6, 1, 8, 30, 0, 0, time.UTC), End: time.Date(2026, 6, 1, 9, 30, 0, 0, time.UTC)}, + }, + Tzid: "Etc/UTC", + DurationMinutes: 60, + MinimumNoticeMinutes: 0, + BookingHorizonDays: 1, + Now: now, + }) + if err != nil { + t.Fatalf("derive slots: %v", err) + } + got := formatAll(slots, time.UTC) + want := []string{"10:00", "11:00", "12:00", "13:00", "14:00", "15:00", "16:00"} + if !equalStrings(got, want) { + t.Fatalf("expected only the 09:00 slot removed, got %v", got) + } +} + +func TestDeriveSlots_BusyIntervalStraddlingRangeEnd(t *testing.T) { + now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + weekday := int(now.Weekday()) + + slots, err := DeriveSlots(Params{ + Ranges: []Range{{Weekday: weekday, StartMinute: 9 * 60, EndMinute: 17 * 60}}, + Busy: []BusyInterval{ + {Start: time.Date(2026, 6, 1, 16, 30, 0, 0, time.UTC), End: time.Date(2026, 6, 1, 17, 30, 0, 0, time.UTC)}, + }, + Tzid: "Etc/UTC", + DurationMinutes: 60, + MinimumNoticeMinutes: 0, + BookingHorizonDays: 1, + Now: now, + }) + if err != nil { + t.Fatalf("derive slots: %v", err) + } + got := formatAll(slots, time.UTC) + want := []string{"09:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00"} + if !equalStrings(got, want) { + t.Fatalf("expected only the 16:00 slot removed, got %v", got) + } +} + +func TestDeriveSlots_BusyIntervalCoveringARangeEntirely(t *testing.T) { + now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + weekday := int(now.Weekday()) + + slots, err := DeriveSlots(Params{ + Ranges: []Range{{Weekday: weekday, StartMinute: 9 * 60, EndMinute: 17 * 60}}, + Busy: []BusyInterval{ + {Start: time.Date(2026, 6, 1, 8, 0, 0, 0, time.UTC), End: time.Date(2026, 6, 1, 18, 0, 0, 0, time.UTC)}, + }, + Tzid: "Etc/UTC", + DurationMinutes: 60, + MinimumNoticeMinutes: 0, + BookingHorizonDays: 1, + Now: now, + }) + if err != nil { + t.Fatalf("derive slots: %v", err) + } + if len(slots) != 0 { + t.Fatalf("expected a Busy interval covering the whole range to remove every slot, got %v", slots) + } +} + +// TestDeriveSlots_AdjacentIntervalsLeaveAnExactDurationGap covers the AC +// line directly: two Busy intervals with an exact-Duration gap between them +// must still leave that gap's one slot bookable. +func TestDeriveSlots_AdjacentIntervalsLeaveAnExactDurationGap(t *testing.T) { + now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + weekday := int(now.Weekday()) + + slots, err := DeriveSlots(Params{ + Ranges: []Range{{Weekday: weekday, StartMinute: 9 * 60, EndMinute: 17 * 60}}, + Busy: []BusyInterval{ + {Start: time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC), End: time.Date(2026, 6, 1, 11, 0, 0, 0, time.UTC)}, + {Start: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), End: time.Date(2026, 6, 1, 17, 0, 0, 0, time.UTC)}, + }, + Tzid: "Etc/UTC", + DurationMinutes: 60, + MinimumNoticeMinutes: 0, + BookingHorizonDays: 1, + Now: now, + }) + if err != nil { + t.Fatalf("derive slots: %v", err) + } + got := formatAll(slots, time.UTC) + want := []string{"11:00"} + if !equalStrings(got, want) { + t.Fatalf("expected exactly the 11:00 gap slot, got %v", got) + } +} + +// TestDeriveSlots_DurationNotDividingARangeEvenly covers the AC line +// directly: a 50-minute range with a 30-minute Duration fits exactly one +// slot, the leftover 20 minutes going unused rather than producing a +// short slot. +func TestDeriveSlots_DurationNotDividingARangeEvenly(t *testing.T) { + now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + weekday := int(now.Weekday()) + + slots, err := DeriveSlots(Params{ + Ranges: []Range{{Weekday: weekday, StartMinute: 9 * 60, EndMinute: 9*60 + 50}}, + Tzid: "Etc/UTC", + DurationMinutes: 30, + MinimumNoticeMinutes: 0, + BookingHorizonDays: 1, + Now: now, + }) + if err != nil { + t.Fatalf("derive slots: %v", err) + } + got := formatAll(slots, time.UTC) + want := []string{"09:00"} + if !equalStrings(got, want) { + t.Fatalf("expected exactly one slot with the 20-minute remainder unused, got %v", got) + } +} + +// TestDeriveSlots_MinimumNoticeTruncatesTheNearEdge covers the AC line +// directly. +func TestDeriveSlots_MinimumNoticeTruncatesTheNearEdge(t *testing.T) { + now := time.Date(2026, 6, 1, 9, 30, 0, 0, time.UTC) + weekday := int(now.Weekday()) + + slots, err := DeriveSlots(Params{ + Ranges: []Range{{Weekday: weekday, StartMinute: 9 * 60, EndMinute: 17 * 60}}, + Tzid: "Etc/UTC", + DurationMinutes: 60, + MinimumNoticeMinutes: 90, + BookingHorizonDays: 1, + Now: now, + }) + if err != nil { + t.Fatalf("derive slots: %v", err) + } + got := formatAll(slots, time.UTC) + want := []string{"11:00", "12:00", "13:00", "14:00", "15:00", "16:00"} + if !equalStrings(got, want) { + t.Fatalf("expected 09:00 and 10:00 truncated by minimum notice, got %v", got) + } +} + +// TestDeriveSlots_BookingHorizonBoundsTheFarEdge covers the AC line +// directly: a schedule matching every weekday still stops producing slots +// once the horizon cutoff is reached. +func TestDeriveSlots_BookingHorizonBoundsTheFarEdge(t *testing.T) { + now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + + everyWeekday := make([]Range, 7) + for i := range everyWeekday { + everyWeekday[i] = Range{Weekday: i, StartMinute: 9 * 60, EndMinute: 17 * 60} + } + + slots, err := DeriveSlots(Params{ + Ranges: everyWeekday, + Tzid: "Etc/UTC", + DurationMinutes: 60, + MinimumNoticeMinutes: 0, + BookingHorizonDays: 1, + Now: now, + }) + if err != nil { + t.Fatalf("derive slots: %v", err) + } + if len(slots) != 8 { + t.Fatalf("expected only today's 8 slots within a 1-day horizon, got %d: %v", len(slots), slots) + } + for _, s := range slots { + if s.Day() != 1 { + t.Fatalf("expected every slot on June 1, got one on day %d: %v", s.Day(), s) + } + } +} + +// TestDeriveSlots_DSTSpringForward covers a range spanning the US +// spring-forward transition (2026-03-08, clocks jump 02:00 -> 03:00): the +// nonexistent 02:00-03:00 hour is skipped, not double-counted or errored, +// and the surviving slots read the wall-clock hours actually stated. +func TestDeriveSlots_DSTSpringForward(t *testing.T) { + loc := mustLoadLocation(t, "America/New_York") + now := time.Date(2026, 3, 8, 0, 0, 0, 0, loc) + weekday := int(now.Weekday()) + + slots, err := DeriveSlots(Params{ + Ranges: []Range{{Weekday: weekday, StartMinute: 60, EndMinute: 240}}, // 01:00-04:00 + Tzid: "America/New_York", + DurationMinutes: 60, + MinimumNoticeMinutes: 0, + BookingHorizonDays: 1, + Now: now, + }) + if err != nil { + t.Fatalf("derive slots: %v", err) + } + got := formatAll(slots, loc) + want := []string{"01:00", "03:00"} + if !equalStrings(got, want) { + t.Fatalf("expected the nonexistent 02:00 hour skipped, got %v", got) + } +} + +// TestDeriveSlots_DSTFallBack covers a range spanning the US fall-back +// transition (2026-11-01, clocks repeat 01:00-02:00): the repeated hour +// produces two genuinely distinct slots, an hour of real time apart, even +// though both display the same wall-clock label. +func TestDeriveSlots_DSTFallBack(t *testing.T) { + loc := mustLoadLocation(t, "America/New_York") + now := time.Date(2026, 11, 1, 0, 0, 0, 0, loc) + weekday := int(now.Weekday()) + + slots, err := DeriveSlots(Params{ + Ranges: []Range{{Weekday: weekday, StartMinute: 0, EndMinute: 180}}, // 00:00-03:00 + Tzid: "America/New_York", + DurationMinutes: 60, + MinimumNoticeMinutes: 0, + BookingHorizonDays: 1, + Now: now, + }) + if err != nil { + t.Fatalf("derive slots: %v", err) + } + got := formatAll(slots, loc) + want := []string{"00:00", "01:00", "01:00", "02:00"} + if !equalStrings(got, want) { + t.Fatalf("expected the repeated 01:00 hour to produce two slots, got %v", got) + } + if len(slots) != 4 { + t.Fatalf("expected 4 slots (a 25-hour day at 60-minute slots), got %d", len(slots)) + } + _, firstOffset := slots[1].Zone() + _, secondOffset := slots[2].Zone() + if firstOffset == secondOffset { + t.Fatalf("expected the two 01:00 slots to carry different UTC offsets (first pass EDT, second EST), got %d both", firstOffset) + } +} + +// TestDeriveSlots_ScheduleZoneDiffersFromCallers covers the AC line +// directly: Now is given as an instant that is already the next calendar +// day in UTC terms, but still the previous day in the Schedule's own zone +// (Pacific/Honolulu, UTC-10, no DST) — the Range on that still-current +// local day must be found by stepping forward from the Schedule's own +// "today", not from Now's day in whatever zone happened to construct it. +func TestDeriveSlots_ScheduleZoneDiffersFromCallers(t *testing.T) { + loc := mustLoadLocation(t, "Pacific/Honolulu") + // 2026-06-02 05:00 UTC is 2026-06-01 19:00 in Honolulu (Monday there, + // Tuesday in UTC) — verified against Go's own tzdata. + now := time.Date(2026, 6, 2, 5, 0, 0, 0, time.UTC) + + slots, err := DeriveSlots(Params{ + Ranges: []Range{{Weekday: int(time.Monday), StartMinute: 20 * 60, EndMinute: 21 * 60}}, + Tzid: "Pacific/Honolulu", + DurationMinutes: 60, + MinimumNoticeMinutes: 0, + BookingHorizonDays: 2, + Now: now, + }) + if err != nil { + t.Fatalf("derive slots: %v", err) + } + if len(slots) != 1 { + t.Fatalf("expected exactly one slot on the Schedule zone's own current day, got %v", slots) + } + want := time.Date(2026, 6, 1, 20, 0, 0, 0, loc) + if !slots[0].Equal(want) { + t.Fatalf("expected the slot at %v (Honolulu's own Monday), got %v", want, slots[0]) + } +} + +func TestDeriveSlots_RejectsNonPositiveDuration(t *testing.T) { + _, err := DeriveSlots(Params{ + Tzid: "Etc/UTC", + DurationMinutes: 0, + BookingHorizonDays: 1, + Now: time.Now(), + }) + if err == nil { + t.Fatalf("expected an error for a non-positive duration") + } +} + +func TestDeriveSlots_RejectsInvalidTimezone(t *testing.T) { + _, err := DeriveSlots(Params{ + Tzid: "Not/AZone", + DurationMinutes: 30, + BookingHorizonDays: 1, + Now: time.Now(), + }) + if err == nil { + t.Fatalf("expected an error for an invalid timezone") + } +} 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..70f3a9b 100644 --- a/server/internal/caldavserver/object.go +++ b/server/internal/caldavserver/object.go @@ -118,12 +118,13 @@ 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 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) { @@ -132,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))) @@ -160,6 +168,7 @@ func seriesWriteFromParsed(parsed *icalendar.ParsedSeries) service.SeriesWrite { Start: o.Start, End: o.End, AllDay: o.AllDay, + Busy: o.Busy, Tzid: o.Tzid, Reminders: o.Reminders, Color: o.Color, @@ -174,6 +183,7 @@ func seriesWriteFromParsed(parsed *icalendar.ParsedSeries) service.SeriesWrite { Start: parsed.Master.Start, End: parsed.Master.End, AllDay: parsed.Master.AllDay, + Busy: parsed.Master.Busy, Tzid: parsed.Master.Tzid, Rrule: parsed.Rrule, Reminders: parsed.Master.Reminders, @@ -186,14 +196,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), @@ -246,6 +261,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/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, " (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/db/migrations/00007_calendar_exposure.sql b/server/internal/db/migrations/00007_calendar_exposure.sql new file mode 100644 index 0000000..8462f7f --- /dev/null +++ b/server/internal/db/migrations/00007_calendar_exposure.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- calendar_exposures is Exposure (#297, ADR-0080, CONTEXT.md's Exposure +-- entry): one User's own choice about whether a Calendar appears in *their +-- own* CalDAV home-set. Keyed and shaped exactly like calendar_user_colors +-- — no indirection, cascading on both Calendar and User — because an +-- Exposure override has the same no-wholesale-replace-to-survive shape a +-- colour override does (ADR-0038). Absent a row, the default is resolved in +-- code rather than stored: exposed, except a Linked Calendar's own Owner, +-- who defaults to unexposed. +CREATE TABLE calendar_exposures ( + calendar_id TEXT NOT NULL REFERENCES calendars(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + exposed BOOLEAN NOT NULL, + PRIMARY KEY (calendar_id, user_id) +); + +-- +goose Down +DROP TABLE calendar_exposures; 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/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/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/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/db/migrations/00012_event_busy.sql b/server/internal/db/migrations/00012_event_busy.sql new file mode 100644 index 0000000..476d37a --- /dev/null +++ b/server/internal/db/migrations/00012_event_busy.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- Busy (#319, ADR-0086): transp holds iCalendar's own TRANSP property +-- (OPAQUE/TRANSPARENT), the field a Booking Link will later read to decide +-- whether an Event closes a slot. Defaults to OPAQUE (Busy) at the column +-- level, matching RFC 5545's own default for a VEVENT with no TRANSP at +-- all — the all-day departure (ADR-0086's "an all-day Event defaults to +-- Free") is applied by the write path that creates one, not by this +-- column, which stays the RFC's default for everything else. Every +-- pre-existing row reads as Busy: backfilling TRANSPARENT onto an existing +-- all-day Event would silently change the meaning of data a User already +-- entered. +ALTER TABLE events ADD COLUMN transp TEXT NOT NULL DEFAULT 'OPAQUE'; + +-- +goose Down +ALTER TABLE events DROP COLUMN transp; diff --git a/server/internal/db/migrations/00013_availability_schedules.sql b/server/internal/db/migrations/00013_availability_schedules.sql new file mode 100644 index 0000000..4e2ec0e --- /dev/null +++ b/server/internal/db/migrations/00013_availability_schedules.sql @@ -0,0 +1,43 @@ +-- +goose Up +-- availability_schedules and availability_schedule_ranges are an +-- Availability Schedule (#320, ADR-0085, CONTEXT.md's Availability Schedule +-- entry): a named weekly pattern of time ranges belonging to one User, +-- carrying its own IANA timezone, from which a Booking Link's slots will be +-- derived (#323). Deliberately not scoped to a Workspace, unlike +-- calendar_sets and task_lists (00009, 00010) — a Schedule belongs to the +-- User alone, and is reusable across every Workspace a Booking Link of +-- theirs might write into. +-- +-- One is auto-created per User, named "Default" and seeded once from that +-- User's working_hours_start/working_hours_end (Mon-Fri 09:00-17:00 when +-- unset); editing Working hours afterwards never reaches a Schedule, ever +-- (ADR-0085's central rule) — there is no foreign key back to +-- working_hours_start/end, only a one-time copy at seed time. +-- +-- Ranges are shaped after calendar_sets' own append-only era (ADR-0048): +-- weekday (0-6, Sunday-Saturday, matching users.week_start's own convention) +-- plus a start/end minute-of-day pair, several per weekday permitted so a +-- split day is representable. +CREATE TABLE availability_schedules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + tzid TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_availability_schedules_user_id ON availability_schedules(user_id); + +CREATE TABLE availability_schedule_ranges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + schedule_id INTEGER NOT NULL REFERENCES availability_schedules(id) ON DELETE CASCADE, + weekday INTEGER NOT NULL, + start_minute INTEGER NOT NULL, + end_minute INTEGER NOT NULL +); + +CREATE INDEX idx_availability_schedule_ranges_schedule_id ON availability_schedule_ranges(schedule_id); + +-- +goose Down +DROP TABLE availability_schedule_ranges; +DROP TABLE availability_schedules; diff --git a/server/internal/db/migrations/00014_users_handle.sql b/server/internal/db/migrations/00014_users_handle.sql new file mode 100644 index 0000000..d72258a --- /dev/null +++ b/server/internal/db/migrations/00014_users_handle.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- handle is a Handle (#321, ADR-0084): a User's public name, the first +-- identifier in this app safe to show a stranger. Nullable — a User who +-- never publishes anything has no Handle (ADR-0084's "seeding one for every +-- existing User at migration time was rejected") — and COLLATE NOCASE, +-- unique on the same case-insensitive terms as email (ADR-0047), except +-- SQLite's ALTER TABLE can't add a UNIQUE column directly, hence the +-- separate index below. Several NULLs are allowed by a UNIQUE index, so +-- every User with no Handle coexists without conflict. +ALTER TABLE users ADD COLUMN handle TEXT COLLATE NOCASE; +CREATE UNIQUE INDEX idx_users_handle ON users(handle); + +-- +goose Down +DROP INDEX idx_users_handle; +ALTER TABLE users DROP COLUMN handle; diff --git a/server/internal/db/migrations/00015_booking_links.sql b/server/internal/db/migrations/00015_booking_links.sql new file mode 100644 index 0000000..b2c92d3 --- /dev/null +++ b/server/internal/db/migrations/00015_booking_links.sql @@ -0,0 +1,63 @@ +-- +goose Up +-- booking_links and booking_link_conflict_calendars are a Booking Link +-- (#322, ADR-0084, ADR-0087, CONTEXT.md's Booking Link entry): a named, +-- slug-addressed offer to be booked, belonging to one User within one +-- Workspace — scoped and cascaded on both exactly like task_lists +-- (00010_task_lists.sql), since a link writes into a Calendar of that +-- Workspace and dies with the membership that gave it one. +-- +-- slug is unique per User, not instance-wide (ADR-0084: "two people may +-- both publish intro-call"), hence UNIQUE(user_id, slug) rather than a +-- bare UNIQUE(slug) the way users.handle is. COLLATE NOCASE folds case the +-- same way handle and email do. +-- +-- availability_schedule_id and book_into_calendar_id carry no ON DELETE +-- clause (SQLite's default, NO ACTION, behaves like RESTRICT under the +-- foreign_keys pragma this app always runs with) — deleting a Schedule or +-- Calendar still referenced by a Booking Link is refused at the database +-- level, which is what backs ADR-0085's "deleting a Schedule still +-- referenced is refused" now that a referencing entity finally exists. +-- +-- minimum_notice_minutes and booking_horizon_days default to ADR-0087's own +-- stated defaults (4 hours, 60 days) so a link created without opening +-- More options is already complete. tasks_in_conflict_set is the Conflict +-- set's "single row standing for Tasks" (CONTEXT.md) — a plain boolean +-- here rather than a synthetic row in the join table below, since Tasks +-- has no calendar_id to put there. +CREATE TABLE booking_links ( + 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, + title TEXT NOT NULL, + slug TEXT NOT NULL COLLATE NOCASE, + duration_minutes INTEGER NOT NULL, + visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public', 'private', 'paused')), + availability_schedule_id INTEGER NOT NULL REFERENCES availability_schedules(id), + book_into_calendar_id TEXT NOT NULL REFERENCES calendars(id), + location TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + minimum_notice_minutes INTEGER NOT NULL DEFAULT 240, + booking_horizon_days INTEGER NOT NULL DEFAULT 60, + tasks_in_conflict_set INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, slug) +); + +CREATE INDEX idx_booking_links_user_id ON booking_links(user_id); +CREATE INDEX idx_booking_links_workspace_id ON booking_links(workspace_id); + +-- The Calendar half of the Conflict set (ADR-0087) — seeded from every +-- Calendar the User owns in the Workspace, plus the Book-into Calendar +-- itself, unremovable. Shaped after calendar_set_members +-- (00009_calendar_sets.sql). +CREATE TABLE booking_link_conflict_calendars ( + link_id INTEGER NOT NULL REFERENCES booking_links(id) ON DELETE CASCADE, + calendar_id TEXT NOT NULL REFERENCES calendars(id) ON DELETE CASCADE, + PRIMARY KEY (link_id, calendar_id) +); + +CREATE INDEX idx_booking_link_conflict_calendars_calendar_id ON booking_link_conflict_calendars(calendar_id); + +-- +goose Down +DROP TABLE booking_link_conflict_calendars; +DROP TABLE booking_links; diff --git a/server/internal/db/migrations/00016_public_booking_rate_limit_scope.sql b/server/internal/db/migrations/00016_public_booking_rate_limit_scope.sql new file mode 100644 index 0000000..bb968fb --- /dev/null +++ b/server/internal/db/migrations/00016_public_booking_rate_limit_scope.sql @@ -0,0 +1,46 @@ +-- +goose Up +-- Widens auth_rate_limit_attempts.scope to admit 'public_booking' (#324, +-- ADR-0087): the public Booking Link page's own per-IP ceiling, throttled +-- the same way Register's is — IP-only, charged on every call. +-- +-- 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 scope CHECK. +CREATE TABLE auth_rate_limit_attempts_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope TEXT NOT NULL CHECK (scope IN ('auth', 'register', 'public_booking')), + key_type TEXT NOT NULL CHECK (key_type IN ('email', 'ip')), + key_value TEXT NOT NULL COLLATE NOCASE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO auth_rate_limit_attempts_new (id, scope, key_type, key_value, created_at) +SELECT id, scope, key_type, key_value, created_at +FROM auth_rate_limit_attempts; + +DROP TABLE auth_rate_limit_attempts; +ALTER TABLE auth_rate_limit_attempts_new RENAME TO auth_rate_limit_attempts; + +CREATE INDEX idx_auth_rate_limit_lookup ON auth_rate_limit_attempts(scope, key_type, key_value, created_at); + +-- +goose Down +-- Any 'public_booking' row is deleted outright, not folded into another +-- scope — there is no existing scope it means the same thing as. +DELETE FROM auth_rate_limit_attempts WHERE scope = 'public_booking'; + +CREATE TABLE auth_rate_limit_attempts_old ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope TEXT NOT NULL CHECK (scope IN ('auth', 'register')), + key_type TEXT NOT NULL CHECK (key_type IN ('email', 'ip')), + key_value TEXT NOT NULL COLLATE NOCASE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO auth_rate_limit_attempts_old (id, scope, key_type, key_value, created_at) +SELECT id, scope, key_type, key_value, created_at +FROM auth_rate_limit_attempts; + +DROP TABLE auth_rate_limit_attempts; +ALTER TABLE auth_rate_limit_attempts_old RENAME TO auth_rate_limit_attempts; + +CREATE INDEX idx_auth_rate_limit_lookup ON auth_rate_limit_attempts(scope, key_type, key_value, created_at); diff --git a/server/internal/db/migrations/00017_booking_cancellation_notice.sql b/server/internal/db/migrations/00017_booking_cancellation_notice.sql new file mode 100644 index 0000000..6528bc4 --- /dev/null +++ b/server/internal/db/migrations/00017_booking_cancellation_notice.sql @@ -0,0 +1,80 @@ +-- +goose Up +-- Cancelling a booking (#327, ADR-0087): the visitor's confirmation mail +-- carries a signed cancel link, and cancelling emails the host — neither is +-- an Invitation or a Cancellation in ADR-0059's sense (no METHOD:REQUEST/ +-- CANCEL card, no Attendee lifecycle), so both are one new mail method, +-- 'BOOKING_NOTICE', carrying a fully rendered subject/body rather than a +-- typed snapshot InvitationSender interprets — the sender doesn't need to +-- know which of the two it is, only how to relay it. +-- +-- 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 mail method 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', 'BOOKING_NOTICE', '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', 'BOOKING_NOTICE') 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 +-- Any 'BOOKING_NOTICE' row is deleted outright, not folded into another +-- method — there is no existing method it means the same thing as. +DELETE FROM outbox WHERE method = 'BOOKING_NOTICE'; + +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', '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_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, status, 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/handlers/auth.go b/server/internal/handlers/auth.go index 3d2de6f..f7eccad 100644 --- a/server/internal/handlers/auth.go +++ b/server/internal/handlers/auth.go @@ -119,6 +119,12 @@ var updateNameErrors = []errorCase{ {service.ErrInvalidDisplayName, badRequest("name must contain a visible character, must not contain control characters, and must be at most 100 characters")}, } +var updateHandleErrors = []errorCase{ + {service.ErrInvalidHandle, badRequest(service.ErrInvalidHandle.Error())}, + {service.ErrHandleReserved, badRequest(service.ErrHandleReserved.Error())}, + {service.ErrHandleTaken, conflict("handle_taken", "handle is already taken")}, +} + var updatePreferencesErrors = []errorCase{ {service.ErrInvalidWeekStart, badRequest("week_start must be between 0 and 6")}, {service.ErrInvalidDefaultView, badRequest("default_view must be one of day, week, month, year")}, @@ -371,6 +377,9 @@ type meResponse struct { TimeFormat string `json:"time_format"` WorkingHoursStart *int `json:"working_hours_start"` WorkingHoursEnd *int `json:"working_hours_end"` + // Handle is a User's public name (#321, ADR-0084) — nil until claimed in + // Settings → Account. + Handle *string `json:"handle"` } func (h *AuthHandler) toMeResponse(user repository.User) meResponse { @@ -388,6 +397,7 @@ func (h *AuthHandler) toMeResponse(user repository.User) meResponse { TimeFormat: user.TimeFormat, WorkingHoursStart: user.WorkingHoursStart, WorkingHoursEnd: user.WorkingHoursEnd, + Handle: user.Handle, } } @@ -446,6 +456,50 @@ func (h *AuthHandler) UpdateName(w http.ResponseWriter, r *http.Request) { httpresponse.JSON(w, http.StatusOK, h.toMeResponse(user)) } +type updateHandleRequest struct { + Handle string `json:"handle"` +} + +// UpdateHandle claims or changes the caller's own Handle — a User's public +// name (#321, ADR-0084). Warning the caller that a rename breaks every URL +// published under the old one is the frontend's job (nothing is published +// under one yet in this codebase); this endpoint just performs the change. +func (h *AuthHandler) UpdateHandle(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + + req, ok := decodeJSON[updateHandleRequest](w, r) + if !ok { + return + } + + user, err := h.auth.UpdateHandle(r.Context(), userID, req.Handle) + if respondError(w, err, updateHandleErrors, "failed to update handle") { + return + } + + httpresponse.JSON(w, http.StatusOK, h.toMeResponse(user)) +} + +type handleSuggestionResponse struct { + Handle string `json:"handle"` +} + +// SuggestHandle serves GET /api/auth/handle-suggestion: a Handle suggestion +// derived from the caller's own Email local part, disambiguated against +// every already-claimed Handle and the reserved-word registry (#321, +// ADR-0084). Read-only — claiming it is a separate UpdateHandle call. +func (h *AuthHandler) SuggestHandle(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + + suggestion, err := h.auth.SuggestHandle(r.Context(), userID) + if err != nil { + httpresponse.Error(w, http.StatusInternalServerError, "internal_error", "failed to suggest handle") + return + } + + httpresponse.JSON(w, http.StatusOK, handleSuggestionResponse{Handle: suggestion}) +} + type updateSyncedDeviceRemindersRequest struct { SyncedDeviceRemindersEnabled bool `json:"synced_device_reminders_enabled"` } diff --git a/server/internal/handlers/auth_test.go b/server/internal/handlers/auth_test.go index 65ed409..9dfcc9e 100644 --- a/server/internal/handlers/auth_test.go +++ b/server/internal/handlers/auth_test.go @@ -62,6 +62,8 @@ func newAuthTestServerWithCookieSecure(t *testing.T, smtpConfigured, imapConfigu r.With(httpauth.RequireAuth(auth), httpauth.RequireActiveUser(auth), httpauth.RequireEnabledUser(auth)).Get("/api/auth/me", h.Me) r.With(httpauth.RequireAuth(auth), httpauth.RequireActiveUser(auth), httpauth.RequireEnabledUser(auth)).Put("/api/auth/email", h.UpdateEmail) r.With(httpauth.RequireAuth(auth), httpauth.RequireActiveUser(auth), httpauth.RequireEnabledUser(auth)).Put("/api/auth/name", h.UpdateName) + r.With(httpauth.RequireAuth(auth), httpauth.RequireActiveUser(auth), httpauth.RequireEnabledUser(auth)).Get("/api/auth/handle-suggestion", h.SuggestHandle) + r.With(httpauth.RequireAuth(auth), httpauth.RequireActiveUser(auth), httpauth.RequireEnabledUser(auth)).Put("/api/auth/handle", h.UpdateHandle) r.With(httpauth.RequireAuth(auth), httpauth.RequireActiveUser(auth), httpauth.RequireEnabledUser(auth)).Put("/api/auth/synced-device-reminders", h.UpdateSyncedDeviceReminders) r.With(httpauth.RequireAuth(auth), httpauth.RequireActiveUser(auth), httpauth.RequireEnabledUser(auth)).Patch("/api/auth/preferences", h.UpdatePreferences) @@ -807,6 +809,162 @@ func TestUpdateName_DuplicateNameIsAllowed(t *testing.T) { } } +// TestMe_HandleAbsentByDefault covers ADR-0084: a User who never claims a +// Handle stays in that legal state — /me reports it as null. +func TestMe_HandleAbsentByDefault(t *testing.T) { + srv := newAuthTestServer(t) + accessToken := authenticatedAccessToken(t, srv) + + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET /api/auth/me: %v", err) + } + defer resp.Body.Close() + + var me meResponse + if err := json.NewDecoder(resp.Body).Decode(&me); err != nil { + t.Fatalf("decode response: %v", err) + } + if me.Handle != nil { + t.Fatalf("expected a fresh User to have no Handle, got %+v", *me.Handle) + } +} + +func TestUpdateHandle_ClaimsAHandle(t *testing.T) { + srv := newAuthTestServer(t) + accessToken := authenticatedAccessToken(t, srv) + + body, _ := json.Marshal(updateHandleRequest{Handle: "damir"}) + req, _ := http.NewRequest(http.MethodPut, srv.URL+"/api/auth/handle", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("PUT /api/auth/handle: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var me meResponse + if err := json.NewDecoder(resp.Body).Decode(&me); err != nil { + t.Fatalf("decode response: %v", err) + } + if me.Handle == nil || *me.Handle != "damir" { + t.Fatalf("expected handle %q, got %+v", "damir", me.Handle) + } +} + +// TestUpdateHandle_RejectsReservedWord covers the AC's reserved-word list +// directly at the HTTP layer — service/auth_test.go's TestUpdateHandle_ +// RejectsEveryReservedWord already covers every entry, so this only proves +// the 400 status code makes it through respondError. +func TestUpdateHandle_RejectsReservedWord(t *testing.T) { + srv := newAuthTestServer(t) + accessToken := authenticatedAccessToken(t, srv) + + body, _ := json.Marshal(updateHandleRequest{Handle: "settings"}) + req, _ := http.NewRequest(http.MethodPut, srv.URL+"/api/auth/handle", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("PUT /api/auth/handle: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +// TestUpdateHandle_DuplicateReturnsConflict covers ADR-0084's uniqueness at +// the HTTP layer, mirroring TestUpdateName_DuplicateNameIsAllowed's shape +// but asserting the opposite: unlike Name, a Handle is unique instance-wide. +func TestUpdateHandle_DuplicateReturnsConflict(t *testing.T) { + g := newTestGraph(t) + + users := g.UserRepo + auth := g.Auth + bootstrapUser, _, err := auth.Bootstrap(context.Background()) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + bob, err := users.Create(context.Background(), "bob", "bob@example.com", "hash", false) + if err != nil { + t.Fatalf("create bob: %v", err) + } + if _, err := auth.UpdateHandle(context.Background(), bob.ID, "bob"); err != nil { + t.Fatalf("bob claims handle: %v", err) + } + if _, err := auth.ChangePassword(context.Background(), bootstrapUser.ID, "admin", "a-new-password"); err != nil { + t.Fatalf("change password: %v", err) + } + + h := NewAuthHandler(auth, g.RateLimiter, false, false, false, true) + r := chi.NewRouter() + r.Post("/api/auth/login", h.Login) + r.With(httpauth.RequireAuth(auth), httpauth.RequireActiveUser(auth)).Put("/api/auth/handle", h.UpdateHandle) + srv := httptest.NewServer(r) + t.Cleanup(srv.Close) + + loginResp := login(t, srv, "admin@example.com", "a-new-password") + defer loginResp.Body.Close() + accessToken := mustLoginAccessToken(t, loginResp) + + body, _ := json.Marshal(updateHandleRequest{Handle: "bob"}) + req, _ := http.NewRequest(http.MethodPut, srv.URL+"/api/auth/handle", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("PUT /api/auth/handle: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusConflict { + t.Fatalf("expected 409, got %d", resp.StatusCode) + } +} + +// TestSuggestHandle_SkipsTheBootstrapAccountsReservedLocalPart covers a real +// edge case rather than a contrived one: the bootstrap account this whole +// file's shared helper seeds is admin@example.com, whose local part is +// itself a reserved word. +func TestSuggestHandle_SkipsTheBootstrapAccountsReservedLocalPart(t *testing.T) { + srv := newAuthTestServer(t) + accessToken := authenticatedAccessToken(t, srv) + + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/auth/handle-suggestion", nil) + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET /api/auth/handle-suggestion: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var suggestion handleSuggestionResponse + if err := json.NewDecoder(resp.Body).Decode(&suggestion); err != nil { + t.Fatalf("decode response: %v", err) + } + if suggestion.Handle != "admin-2" { + t.Fatalf("expected suggestion %q, got %q", "admin-2", suggestion.Handle) + } +} + func TestMe_SyncedDeviceRemindersEnabled_DefaultsFalse(t *testing.T) { srv := newAuthTestServer(t) accessToken := authenticatedAccessToken(t, srv) diff --git a/server/internal/handlers/availability_schedule.go b/server/internal/handlers/availability_schedule.go new file mode 100644 index 0000000..a4d77ba --- /dev/null +++ b/server/internal/handlers/availability_schedule.go @@ -0,0 +1,153 @@ +// availability_schedule.go implements AvailabilityScheduleHandler: +// List/Create/Update/Delete backing the Availability Schedules Settings +// Section (#320, ADR-0085). A Schedule is private outright and carries no +// Workspace of its own, so every route resolves by the caller's own userID +// alone and renders a mismatch as 404 — a Schedule 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 AvailabilityScheduleHandler struct { + schedules *service.AvailabilityScheduleService +} + +func NewAvailabilityScheduleHandler(schedules *service.AvailabilityScheduleService) *AvailabilityScheduleHandler { + return &AvailabilityScheduleHandler{schedules: schedules} +} + +type availabilityRangeDTO struct { + Weekday int `json:"weekday"` + StartMinute int `json:"startMinute"` + EndMinute int `json:"endMinute"` +} + +type availabilityScheduleResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Tzid string `json:"tzid"` + Ranges []availabilityRangeDTO `json:"ranges"` +} + +func toAvailabilityScheduleResponse(schedule repository.AvailabilitySchedule) availabilityScheduleResponse { + ranges := make([]availabilityRangeDTO, len(schedule.Ranges)) + for i, rng := range schedule.Ranges { + ranges[i] = availabilityRangeDTO{Weekday: rng.Weekday, StartMinute: rng.StartMinute, EndMinute: rng.EndMinute} + } + return availabilityScheduleResponse{ID: schedule.ID, Name: schedule.Name, Tzid: schedule.Tzid, Ranges: ranges} +} + +func toAvailabilityRanges(dtos []availabilityRangeDTO) []repository.AvailabilityRange { + ranges := make([]repository.AvailabilityRange, len(dtos)) + for i, dto := range dtos { + ranges[i] = repository.AvailabilityRange{Weekday: dto.Weekday, StartMinute: dto.StartMinute, EndMinute: dto.EndMinute} + } + return ranges +} + +// availabilityScheduleErrors renders the sentinels every +// AvailabilityScheduleService call can return: an empty name, an invalid +// timezone, an invalid range, and repository.ErrNotFound for a Schedule that +// doesn't exist or belongs to another User — the two are indistinguishable +// by design. +var availabilityScheduleErrors = []errorCase{ + {service.ErrInvalidScheduleName, badRequest(service.ErrInvalidScheduleName.Error())}, + {service.ErrInvalidTimezone, badRequest(service.ErrInvalidTimezone.Error())}, + {service.ErrInvalidAvailabilityRange, badRequest(service.ErrInvalidAvailabilityRange.Error())}, + {service.ErrScheduleReferenced, conflict("schedule_referenced", service.ErrScheduleReferenced.Error())}, + {repository.ErrNotFound, notFound("availability schedule not found")}, +} + +// List serves GET /api/availability-schedules: every Schedule the caller +// owns, seeding a "Default" one first if they have none yet (ADR-0085). tz, +// an optional query param carrying the caller's own browser-detected IANA +// zone, seeds that Default Schedule's timezone the one time it doesn't exist +// yet — ignored otherwise. +func (h *AvailabilityScheduleHandler) List(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + + schedules, err := h.schedules.ListForUser(r.Context(), userID, r.URL.Query().Get("tz")) + if respondError(w, err, availabilityScheduleErrors, "failed to list availability schedules") { + return + } + + response := make([]availabilityScheduleResponse, len(schedules)) + for i, s := range schedules { + response[i] = toAvailabilityScheduleResponse(s) + } + + httpresponse.JSON(w, http.StatusOK, response) +} + +type createAvailabilityScheduleRequest struct { + Name string `json:"name"` + Tzid string `json:"tzid"` + Ranges []availabilityRangeDTO `json:"ranges"` +} + +// Create makes a new Schedule for the caller. +func (h *AvailabilityScheduleHandler) Create(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + + req, ok := decodeJSON[createAvailabilityScheduleRequest](w, r) + if !ok { + return + } + + schedule, err := h.schedules.Create(r.Context(), userID, req.Name, req.Tzid, toAvailabilityRanges(req.Ranges)) + if respondError(w, err, availabilityScheduleErrors, "failed to create availability schedule") { + return + } + + httpresponse.JSON(w, http.StatusCreated, toAvailabilityScheduleResponse(schedule)) +} + +// updateAvailabilityScheduleRequest is the same wholesale-replace shape as +// createAvailabilityScheduleRequest — Update, like Create, always takes +// name, timezone and the entire range set together, so there is no +// partial-update variant to give it a distinct shape. +type updateAvailabilityScheduleRequest = createAvailabilityScheduleRequest + +// Update replaces a Schedule's name, timezone and entire weekly range set at +// once. +func (h *AvailabilityScheduleHandler) Update(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + id, ok := parseInt64Param(w, r, "id") + if !ok { + return + } + + req, ok := decodeJSON[updateAvailabilityScheduleRequest](w, r) + if !ok { + return + } + + schedule, err := h.schedules.Update(r.Context(), userID, id, req.Name, req.Tzid, toAvailabilityRanges(req.Ranges)) + if respondError(w, err, availabilityScheduleErrors, "failed to update availability schedule") { + return + } + + httpresponse.JSON(w, http.StatusOK, toAvailabilityScheduleResponse(schedule)) +} + +// Delete removes a Schedule outright. +func (h *AvailabilityScheduleHandler) Delete(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + id, ok := parseInt64Param(w, r, "id") + if !ok { + return + } + + if err := h.schedules.Delete(r.Context(), userID, id); respondError(w, err, availabilityScheduleErrors, "failed to delete availability schedule") { + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/internal/handlers/availability_schedule_test.go b/server/internal/handlers/availability_schedule_test.go new file mode 100644 index 0000000..37ea3b9 --- /dev/null +++ b/server/internal/handlers/availability_schedule_test.go @@ -0,0 +1,538 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "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" +) + +// availabilityScheduleTestServer bundles the HTTP surface Availability +// Schedules need (#320, ADR-0085): Register (to mint real Users, since a +// Schedule is private to one User with no Workspace of its own), the +// Preferences PATCH (to exercise Working hours seeding and the +// never-synchronised rule), and the /api/availability-schedules routes, +// gated the same way router.New wires them — RequireAuth/RequireEnabledUser +// alone, with no RequireWorkspace. +type availabilityScheduleTestServer struct { + srv *httptest.Server + graph *service.Graph +} + +func newAvailabilityScheduleTestServer(t *testing.T) *availabilityScheduleTestServer { + t.Helper() + + cfg := apptest.Config(t) + cfg.InitialName, cfg.InitialEmail, cfg.InitialPassword = "", "", "" + cfg.EnableSignups = true + g := newTestGraphWithConfig(t, cfg) + + auth := g.Auth + authHandler := NewAuthHandler(auth, g.RateLimiter, false, false, false, true) + scheduleHandler := NewAvailabilityScheduleHandler(g.AvailabilitySchedules) + + r := chi.NewRouter() + r.Post("/api/auth/register", authHandler.Register) + r.With(httpauth.RequireAuth(auth)).Patch("/api/auth/preferences", authHandler.UpdatePreferences) + + r.Route("/api/availability-schedules", func(r chi.Router) { + r.Use(httpauth.RequireAuth(auth)) + r.Use(httpauth.RequireEnabledUser(auth)) + + r.Get("/", scheduleHandler.List) + r.Post("/", scheduleHandler.Create) + r.Patch("/{id}", scheduleHandler.Update) + r.Delete("/{id}", scheduleHandler.Delete) + }) + + srv := httptest.NewServer(r) + t.Cleanup(srv.Close) + + return &availabilityScheduleTestServer{srv: srv, graph: g} +} + +func (s *availabilityScheduleTestServer) register(t *testing.T, username string) (accessToken string, userID 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) + } + + user, err := s.graph.UserRepo.GetByEmail(ctx, username+"@example.com") + if err != nil { + t.Fatalf("get %s: %v", username, err) + } + + return logged.AccessToken, user.ID +} + +func (s *availabilityScheduleTestServer) setWorkingHours(t *testing.T, accessToken string, start, end int) { + t.Helper() + + resp := s.do(t, http.MethodPatch, "/api/auth/preferences", accessToken, updatePreferencesRequest{WorkingHoursStart: &start, WorkingHoursEnd: &end}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 setting working hours, got %d", resp.StatusCode) + } +} + +func (s *availabilityScheduleTestServer) list(t *testing.T, accessToken, tz string) []availabilityScheduleResponse { + t.Helper() + + path := "/api/availability-schedules/" + if tz != "" { + path += "?tz=" + tz + } + resp := s.do(t, http.MethodGet, path, accessToken, nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 listing availability schedules, got %d", resp.StatusCode) + } + var list []availabilityScheduleResponse + if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { + t.Fatalf("decode list response: %v", err) + } + return list +} + +func (s *availabilityScheduleTestServer) create(t *testing.T, accessToken string, req createAvailabilityScheduleRequest) *http.Response { + t.Helper() + return s.do(t, http.MethodPost, "/api/availability-schedules/", accessToken, req) +} + +func (s *availabilityScheduleTestServer) do(t *testing.T, method, path, accessToken string, 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") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + return resp +} + +func TestAvailabilityScheduleHandler_List_SeedsDefaultFromFallbackWhenNoWorkingHours(t *testing.T) { + s := newAvailabilityScheduleTestServer(t) + token, _ := s.register(t, "alice") + + schedules := s.list(t, token, "") + if len(schedules) != 1 { + t.Fatalf("expected exactly one auto-seeded schedule, got %v", schedules) + } + def := schedules[0] + if def.Name != "Default" { + t.Fatalf("expected name %q, got %q", "Default", def.Name) + } + if def.Tzid != "Etc/UTC" { + t.Fatalf("expected fallback tzid %q, got %q", "Etc/UTC", def.Tzid) + } + if len(def.Ranges) != 5 { + t.Fatalf("expected Mon-Fri (5 ranges), got %v", def.Ranges) + } + for _, rng := range def.Ranges { + if rng.Weekday < 1 || rng.Weekday > 5 { + t.Fatalf("expected only Mon-Fri weekdays, got weekday %d", rng.Weekday) + } + if rng.StartMinute != 9*60 || rng.EndMinute != 17*60 { + t.Fatalf("expected the 09:00-17:00 fallback, got %d-%d", rng.StartMinute, rng.EndMinute) + } + } +} + +func TestAvailabilityScheduleHandler_List_SeedsDefaultFromWorkingHoursWhenSet(t *testing.T) { + s := newAvailabilityScheduleTestServer(t) + token, _ := s.register(t, "alice") + + s.setWorkingHours(t, token, 8*60, 16*60) + + schedules := s.list(t, token, "") + if len(schedules) != 1 { + t.Fatalf("expected exactly one auto-seeded schedule, got %v", schedules) + } + for _, rng := range schedules[0].Ranges { + if rng.StartMinute != 8*60 || rng.EndMinute != 16*60 { + t.Fatalf("expected the seeded 08:00-16:00 range copied from Working hours, got %d-%d", rng.StartMinute, rng.EndMinute) + } + } +} + +func TestAvailabilityScheduleHandler_List_SeedsTzidFromHint(t *testing.T) { + s := newAvailabilityScheduleTestServer(t) + token, _ := s.register(t, "alice") + + schedules := s.list(t, token, "Europe/Sarajevo") + if schedules[0].Tzid != "Europe/Sarajevo" { + t.Fatalf("expected the seeded schedule to take the browser-detected tz hint, got %q", schedules[0].Tzid) + } +} + +func TestAvailabilityScheduleHandler_List_InvalidTzHintFallsBackToUTC(t *testing.T) { + s := newAvailabilityScheduleTestServer(t) + token, _ := s.register(t, "alice") + + schedules := s.list(t, token, "Not/AZone") + if schedules[0].Tzid != "Etc/UTC" { + t.Fatalf("expected an unresolvable tz hint to fall back to Etc/UTC, got %q", schedules[0].Tzid) + } +} + +// TestAvailabilityScheduleHandler_EditingWorkingHoursAfterwardsNeverReachesTheSchedule +// is ADR-0085's central rule: the Default Schedule is seeded once, and +// changing Working hours afterwards must never touch it, ever. +func TestAvailabilityScheduleHandler_EditingWorkingHoursAfterwardsNeverReachesTheSchedule(t *testing.T) { + s := newAvailabilityScheduleTestServer(t) + token, _ := s.register(t, "alice") + + seeded := s.list(t, token, "") + if seeded[0].Ranges[0].StartMinute != 9*60 || seeded[0].Ranges[0].EndMinute != 17*60 { + t.Fatalf("expected the initial fallback seed, got %v", seeded[0].Ranges) + } + + s.setWorkingHours(t, token, 6*60, 14*60) + + after := s.list(t, token, "") + if len(after) != 1 { + t.Fatalf("expected still exactly one schedule, got %v", after) + } + for _, rng := range after[0].Ranges { + if rng.StartMinute != 9*60 || rng.EndMinute != 17*60 { + t.Fatalf("expected the Default schedule to stay at its originally seeded 09:00-17:00 despite the Working hours change, got %d-%d", rng.StartMinute, rng.EndMinute) + } + } +} + +func TestAvailabilityScheduleHandler_Create_SplitDayAllowsSeveralRangesOnOneWeekday(t *testing.T) { + s := newAvailabilityScheduleTestServer(t) + token, _ := s.register(t, "alice") + + req := createAvailabilityScheduleRequest{ + Name: "Mornings and evenings", + Tzid: "Europe/Berlin", + Ranges: []availabilityRangeDTO{ + {Weekday: 1, StartMinute: 9 * 60, EndMinute: 12 * 60}, + {Weekday: 1, StartMinute: 14 * 60, EndMinute: 17 * 60}, + }, + } + resp := s.create(t, token, req) + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201, got %d", resp.StatusCode) + } + var created availabilityScheduleResponse + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + t.Fatalf("decode create response: %v", err) + } + if len(created.Ranges) != 2 { + t.Fatalf("expected both ranges on the split day to survive, got %v", created.Ranges) + } +} + +func TestAvailabilityScheduleHandler_Create_EmptyScheduleIsLegal(t *testing.T) { + s := newAvailabilityScheduleTestServer(t) + token, _ := s.register(t, "alice") + + resp := s.create(t, token, createAvailabilityScheduleRequest{Name: "Unavailable", Tzid: "Etc/UTC", Ranges: nil}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 creating an empty schedule, got %d", resp.StatusCode) + } + var created availabilityScheduleResponse + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + t.Fatalf("decode create response: %v", err) + } + if len(created.Ranges) != 0 { + t.Fatalf("expected no ranges, got %v", created.Ranges) + } +} + +func TestAvailabilityScheduleHandler_Create_EmptyNameRejected(t *testing.T) { + s := newAvailabilityScheduleTestServer(t) + token, _ := s.register(t, "alice") + + resp := s.create(t, token, createAvailabilityScheduleRequest{Name: " ", Tzid: "Etc/UTC"}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestAvailabilityScheduleHandler_Create_InvalidTimezoneRejected(t *testing.T) { + s := newAvailabilityScheduleTestServer(t) + token, _ := s.register(t, "alice") + + resp := s.create(t, token, createAvailabilityScheduleRequest{Name: "Work", Tzid: "Not/AZone"}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestAvailabilityScheduleHandler_Create_InvalidRangeRejected(t *testing.T) { + s := newAvailabilityScheduleTestServer(t) + token, _ := s.register(t, "alice") + + cases := []availabilityRangeDTO{ + {Weekday: 7, StartMinute: 0, EndMinute: 60}, + {Weekday: -1, StartMinute: 0, EndMinute: 60}, + {Weekday: 1, StartMinute: 60, EndMinute: 60}, + {Weekday: 1, StartMinute: 120, EndMinute: 60}, + {Weekday: 1, StartMinute: 0, EndMinute: 24*60 + 1}, + } + for _, rng := range cases { + resp := s.create(t, token, createAvailabilityScheduleRequest{Name: "Work", Tzid: "Etc/UTC", Ranges: []availabilityRangeDTO{rng}}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for range %+v, got %d", rng, resp.StatusCode) + } + } +} + +func TestAvailabilityScheduleHandler_Update_ReplacesNameTimezoneAndRanges(t *testing.T) { + s := newAvailabilityScheduleTestServer(t) + token, _ := s.register(t, "alice") + + createResp := s.create(t, token, createAvailabilityScheduleRequest{ + Name: "Work", + Tzid: "Etc/UTC", + Ranges: []availabilityRangeDTO{{Weekday: 1, StartMinute: 9 * 60, EndMinute: 17 * 60}}, + }) + defer createResp.Body.Close() + var created availabilityScheduleResponse + if err := json.NewDecoder(createResp.Body).Decode(&created); err != nil { + t.Fatalf("decode create response: %v", err) + } + + updateReq := updateAvailabilityScheduleRequest{ + Name: "Weekend calls", + Tzid: "Europe/Sarajevo", + Ranges: []availabilityRangeDTO{ + {Weekday: 6, StartMinute: 10 * 60, EndMinute: 12 * 60}, + }, + } + updateResp := s.do(t, http.MethodPatch, "/api/availability-schedules/"+strconv.FormatInt(created.ID, 10), token, updateReq) + defer updateResp.Body.Close() + if updateResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 updating, got %d", updateResp.StatusCode) + } + var updated availabilityScheduleResponse + if err := json.NewDecoder(updateResp.Body).Decode(&updated); err != nil { + t.Fatalf("decode update response: %v", err) + } + if updated.Name != "Weekend calls" || updated.Tzid != "Europe/Sarajevo" { + t.Fatalf("expected the name and timezone to be replaced, got %+v", updated) + } + if len(updated.Ranges) != 1 || updated.Ranges[0].Weekday != 6 { + t.Fatalf("expected the old range replaced wholesale by the new one, got %v", updated.Ranges) + } +} + +func TestAvailabilityScheduleHandler_SecondUserCannotReadUpdateOrDeleteFirsts(t *testing.T) { + s := newAvailabilityScheduleTestServer(t) + aliceToken, _ := s.register(t, "alice") + bobToken, _ := s.register(t, "bob") + + createResp := s.create(t, aliceToken, createAvailabilityScheduleRequest{Name: "Work", Tzid: "Etc/UTC"}) + defer createResp.Body.Close() + var alicesSchedule availabilityScheduleResponse + if err := json.NewDecoder(createResp.Body).Decode(&alicesSchedule); err != nil { + t.Fatalf("decode create response: %v", err) + } + idPath := "/api/availability-schedules/" + strconv.FormatInt(alicesSchedule.ID, 10) + + updateResp := s.do(t, http.MethodPatch, idPath, bobToken, updateAvailabilityScheduleRequest{Name: "Hijacked", Tzid: "Etc/UTC"}) + defer updateResp.Body.Close() + if updateResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 updating, got %d", updateResp.StatusCode) + } + + deleteResp := s.do(t, http.MethodDelete, idPath, bobToken, nil) + defer deleteResp.Body.Close() + if deleteResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 deleting, got %d", deleteResp.StatusCode) + } + + // alice's own list still shows it, untouched, under its original name. + aliceSchedules := s.list(t, aliceToken, "") + found := false + for _, sched := range aliceSchedules { + if sched.ID == alicesSchedule.ID && sched.Name == "Work" { + found = true + } + } + if !found { + t.Fatalf("expected alice's schedule to survive bob's attempts untouched, got %v", aliceSchedules) + } + + // bob's own list is independent — he never sees alice's schedule at all. + bobSchedules := s.list(t, bobToken, "") + for _, sched := range bobSchedules { + if sched.ID == alicesSchedule.ID { + t.Fatalf("expected bob's own list to never include alice's schedule, got %v", bobSchedules) + } + } +} + +func TestAvailabilityScheduleHandler_Delete_RemovesIt(t *testing.T) { + s := newAvailabilityScheduleTestServer(t) + token, _ := s.register(t, "alice") + + // Seed the Default first so deleting the one we create below doesn't + // drop the caller back to zero schedules, which would re-seed a fresh + // Default on the very next List and make the assertion below ambiguous. + s.list(t, token, "") + + createResp := s.create(t, token, createAvailabilityScheduleRequest{Name: "Temp", Tzid: "Etc/UTC"}) + defer createResp.Body.Close() + var created availabilityScheduleResponse + if err := json.NewDecoder(createResp.Body).Decode(&created); err != nil { + t.Fatalf("decode create response: %v", err) + } + + deleteResp := s.do(t, http.MethodDelete, "/api/availability-schedules/"+strconv.FormatInt(created.ID, 10), token, nil) + defer deleteResp.Body.Close() + if deleteResp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204 deleting, got %d", deleteResp.StatusCode) + } + + remaining := s.list(t, token, "") + if len(remaining) != 1 || remaining[0].Name != "Default" { + t.Fatalf("expected only the Default schedule to remain, got %v", remaining) + } +} + +// TestAvailabilityScheduleHandler_RangeSurvivesADSTTransitionAtItsStatedWallClockHours +// covers ADR-0085's "a range spanning a DST transition yields the wall-clock +// hours stated": a Schedule stores a bare weekday + start/end minute pair +// and a tzid, never a frozen UTC offset, so reconstructing the same +// wall-clock range against two dates either side of a DST changeover (US +// spring-forward, 2026-03-08) must read back the same local hours even +// though the UTC instant underneath shifts by an hour. +func TestAvailabilityScheduleHandler_RangeSurvivesADSTTransitionAtItsStatedWallClockHours(t *testing.T) { + s := newAvailabilityScheduleTestServer(t) + token, _ := s.register(t, "alice") + + resp := s.create(t, token, createAvailabilityScheduleRequest{ + Name: "US hours", + Tzid: "America/New_York", + Ranges: []availabilityRangeDTO{ + {Weekday: 1, StartMinute: 9 * 60, EndMinute: 17 * 60}, + }, + }) + defer resp.Body.Close() + var created availabilityScheduleResponse + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + t.Fatalf("decode create response: %v", err) + } + + loc, err := time.LoadLocation(created.Tzid) + if err != nil { + t.Fatalf("load location %q: %v", created.Tzid, err) + } + rng := created.Ranges[0] + startHour, startMin := rng.StartMinute/60, rng.StartMinute%60 + endHour, endMin := rng.EndMinute/60, rng.EndMinute%60 + + // 2026-03-02 is the Monday before the US DST transition (2026-03-08); + // 2026-03-09 is the Monday after. + before := time.Date(2026, 3, 2, startHour, startMin, 0, 0, loc) + beforeEnd := time.Date(2026, 3, 2, endHour, endMin, 0, 0, loc) + after := time.Date(2026, 3, 9, startHour, startMin, 0, 0, loc) + afterEnd := time.Date(2026, 3, 9, endHour, endMin, 0, 0, loc) + + if before.Hour() != 9 || after.Hour() != 9 { + t.Fatalf("expected the stated 09:00 wall-clock hour on both sides of the DST transition, got %d and %d", before.Hour(), after.Hour()) + } + if beforeEnd.Hour() != 17 || afterEnd.Hour() != 17 { + t.Fatalf("expected the stated 17:00 wall-clock hour on both sides of the DST transition, got %d and %d", beforeEnd.Hour(), afterEnd.Hour()) + } + + _, beforeOffset := before.Zone() + _, afterOffset := after.Zone() + if beforeOffset == afterOffset { + t.Fatalf("expected the UTC offset to actually differ across the DST transition, got %d both sides", beforeOffset) + } +} + +// TestAvailabilityScheduleHandler_CascadesOnUserDeletion covers +// availability_schedules.user_id's ON DELETE CASCADE, which also takes +// availability_schedule_ranges with it via schedule_id's own cascade. +func TestAvailabilityScheduleHandler_CascadesOnUserDeletion(t *testing.T) { + s := newAvailabilityScheduleTestServer(t) + token, userID := s.register(t, "alice") + + created := s.list(t, token, "") + if len(created) != 1 || len(created[0].Ranges) == 0 { + t.Fatalf("expected the auto-seeded Default schedule with ranges, got %v", created) + } + + impact, err := s.graph.Accounts.DeleteImpact(context.Background(), userID) + if err != nil { + t.Fatalf("delete impact: %v", err) + } + dispositions := make([]service.CalendarDisposition, len(impact.Calendars)) + for i, c := range impact.Calendars { + dispositions[i] = service.CalendarDisposition{CalendarID: c.ID, Disposition: service.DispositionDelete} + } + if err := s.graph.Accounts.Delete(context.Background(), userID, dispositions); err != nil { + t.Fatalf("delete user: %v", err) + } + + var scheduleCount int + if err := s.graph.DB.QueryRow("SELECT COUNT(*) FROM availability_schedules WHERE user_id = ?", userID).Scan(&scheduleCount); err != nil { + t.Fatalf("count availability schedules: %v", err) + } + if scheduleCount != 0 { + t.Fatalf("expected deleting the user to cascade every availability schedule they owned, got %d remaining", scheduleCount) + } + + var rangeCount int + if err := s.graph.DB.QueryRow("SELECT COUNT(*) FROM availability_schedule_ranges WHERE schedule_id = ?", created[0].ID).Scan(&rangeCount); err != nil { + t.Fatalf("count availability schedule ranges: %v", err) + } + if rangeCount != 0 { + t.Fatalf("expected the schedule's own ranges to cascade too, got %d remaining", rangeCount) + } +} diff --git a/server/internal/handlers/booking_link.go b/server/internal/handlers/booking_link.go new file mode 100644 index 0000000..a527248 --- /dev/null +++ b/server/internal/handlers/booking_link.go @@ -0,0 +1,297 @@ +// booking_link.go implements BookingLinkHandler: List/Create/Update/Delete/ +// Duplicate back the Booking links sidebar section (#322, ADR-0084, +// ADR-0087), plus AddConflictCalendar/RemoveConflictCalendar for its +// Conflict set. Scoped like Task Lists — every route resolves by (id, +// caller, active workspace) and renders a mismatch as 404, a Booking Link +// belonging to someone else being indistinguishable from one that does not +// exist. +package handlers + +import ( + "net/http" + "strconv" + "time" + + "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" + "github.com/XiovV/calich/server/internal/service" +) + +type BookingLinkHandler struct { + links *service.BookingLinkService +} + +func NewBookingLinkHandler(links *service.BookingLinkService) *BookingLinkHandler { + return &BookingLinkHandler{links: links} +} + +type bookingLinkResponse struct { + ID int64 `json:"id"` + Title string `json:"title"` + Slug string `json:"slug"` + DurationMinutes int `json:"durationMinutes"` + Visibility string `json:"visibility"` + AvailabilityScheduleID int64 `json:"availabilityScheduleId"` + BookIntoCalendarID string `json:"bookIntoCalendarId"` + Location string `json:"location"` + Description string `json:"description"` + MinimumNoticeMinutes int `json:"minimumNoticeMinutes"` + BookingHorizonDays int `json:"bookingHorizonDays"` + TasksInConflictSet bool `json:"tasksInConflictSet"` + ConflictCalendarIDs []string `json:"conflictCalendarIds"` +} + +func toBookingLinkResponse(l repository.BookingLink) bookingLinkResponse { + ids := l.ConflictCalendarIDs + if ids == nil { + ids = []string{} + } + return bookingLinkResponse{ + ID: l.ID, + Title: l.Title, + Slug: l.Slug, + DurationMinutes: l.DurationMinutes, + Visibility: l.Visibility, + AvailabilityScheduleID: l.AvailabilityScheduleID, + BookIntoCalendarID: l.BookIntoCalendarID, + Location: l.Location, + Description: l.Description, + MinimumNoticeMinutes: l.MinimumNoticeMinutes, + BookingHorizonDays: l.BookingHorizonDays, + TasksInConflictSet: l.TasksInConflictSet, + ConflictCalendarIDs: ids, + } +} + +// bookingLinkWriteRequest is Create and Update's shared body shape — Update, +// like Create, always takes every field together (BookingLinkService.Update +// replaces them wholesale), so there is no partial-update variant to give it +// a distinct shape. +type bookingLinkWriteRequest struct { + Title string `json:"title"` + Slug string `json:"slug"` + DurationMinutes int `json:"durationMinutes"` + Visibility string `json:"visibility"` + AvailabilityScheduleID int64 `json:"availabilityScheduleId"` + BookIntoCalendarID string `json:"bookIntoCalendarId"` + Location string `json:"location"` + Description string `json:"description"` + MinimumNoticeMinutes int `json:"minimumNoticeMinutes"` + BookingHorizonDays int `json:"bookingHorizonDays"` + TasksInConflictSet bool `json:"tasksInConflictSet"` +} + +func (req bookingLinkWriteRequest) toWrite() service.BookingLinkWrite { + return service.BookingLinkWrite{ + Title: req.Title, + Slug: req.Slug, + DurationMinutes: req.DurationMinutes, + Visibility: req.Visibility, + AvailabilityScheduleID: req.AvailabilityScheduleID, + BookIntoCalendarID: req.BookIntoCalendarID, + Location: req.Location, + Description: req.Description, + MinimumNoticeMinutes: req.MinimumNoticeMinutes, + BookingHorizonDays: req.BookingHorizonDays, + TasksInConflictSet: req.TasksInConflictSet, + } +} + +// bookingLinkErrors renders the sentinels every BookingLinkService call can +// return, plus repository.ErrNotFound for a Booking Link that doesn't exist, +// belongs to another User, or belongs to another Workspace — the three are +// indistinguishable by design. +var bookingLinkErrors = []errorCase{ + {service.ErrInvalidBookingLinkTitle, badRequest(service.ErrInvalidBookingLinkTitle.Error())}, + {service.ErrInvalidSlug, badRequest(service.ErrInvalidSlug.Error())}, + {service.ErrInvalidDuration, badRequest(service.ErrInvalidDuration.Error())}, + {service.ErrInvalidVisibility, badRequest(service.ErrInvalidVisibility.Error())}, + {service.ErrInvalidMinimumNotice, badRequest(service.ErrInvalidMinimumNotice.Error())}, + {service.ErrInvalidBookingHorizon, badRequest(service.ErrInvalidBookingHorizon.Error())}, + {service.ErrScheduleNotFound, badRequest(service.ErrScheduleNotFound.Error())}, + {service.ErrBookIntoCalendarNotFound, badRequest(service.ErrBookIntoCalendarNotFound.Error())}, + {service.ErrCannotRemoveBookIntoCalendar, badRequest(service.ErrCannotRemoveBookIntoCalendar.Error())}, + {service.ErrSlugTaken, conflict("slug_taken", "slug is already taken")}, + {service.ErrInvalidMonth, badRequest(service.ErrInvalidMonth.Error())}, + {repository.ErrNotFound, notFound("booking link not found")}, +} + +// List serves GET /api/booking-links: every Booking Link the caller owns in +// their active Workspace. +func (h *BookingLinkHandler) List(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + + links, err := h.links.ListForUser(r.Context(), userID, workspaceID) + if respondError(w, err, bookingLinkErrors, "failed to list booking links") { + return + } + + response := make([]bookingLinkResponse, len(links)) + for i, l := range links { + response[i] = toBookingLinkResponse(l) + } + + httpresponse.JSON(w, http.StatusOK, response) +} + +// Create makes a new Booking Link in the caller's active Workspace. Its +// Conflict set is seeded automatically; claims a Handle for the caller if +// this is their first Booking Link and they have none yet. +func (h *BookingLinkHandler) Create(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + workspaceID := httpauth.MustWorkspaceID(r.Context()) + + req, ok := decodeJSON[bookingLinkWriteRequest](w, r) + if !ok { + return + } + + link, err := h.links.Create(r.Context(), userID, workspaceID, req.toWrite()) + if respondError(w, err, bookingLinkErrors, "failed to create booking link") { + return + } + + httpresponse.JSON(w, http.StatusCreated, toBookingLinkResponse(link)) +} + +// Update replaces a Booking Link's fields wholesale. +func (h *BookingLinkHandler) 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[bookingLinkWriteRequest](w, r) + if !ok { + return + } + + link, err := h.links.Update(r.Context(), userID, workspaceID, id, req.toWrite()) + if respondError(w, err, bookingLinkErrors, "failed to update booking link") { + return + } + + httpresponse.JSON(w, http.StatusOK, toBookingLinkResponse(link)) +} + +// Delete removes a Booking Link outright. +func (h *BookingLinkHandler) 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.links.Delete(r.Context(), userID, workspaceID, id); respondError(w, err, bookingLinkErrors, "failed to delete booking link") { + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// Duplicate clones a Booking Link, deriving a fresh Slug and Title and +// copying its Conflict set verbatim. +func (h *BookingLinkHandler) Duplicate(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 + } + + link, err := h.links.Duplicate(r.Context(), userID, workspaceID, id) + if respondError(w, err, bookingLinkErrors, "failed to duplicate booking link") { + return + } + + httpresponse.JSON(w, http.StatusCreated, toBookingLinkResponse(link)) +} + +// AddConflictCalendar serves PUT /api/booking-links/{id}/conflict-set/calendars/{calendarId}: +// puts a Calendar into a Booking Link's Conflict set. PUT, not POST — +// re-adding a Calendar already in the set answers 204 same as a fresh add. +func (h *BookingLinkHandler) AddConflictCalendar(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.links.AddConflictCalendar(r.Context(), userID, workspaceID, id, calendarID); respondError(w, err, bookingLinkErrors, "failed to add conflict calendar") { + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// RemoveConflictCalendar serves DELETE /api/booking-links/{id}/conflict-set/calendars/{calendarId}: +// takes a Calendar out of a Booking Link's Conflict set. Refuses the +// Book-into Calendar — permanently in the set and unremovable (ADR-0087). +func (h *BookingLinkHandler) RemoveConflictCalendar(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.links.RemoveConflictCalendar(r.Context(), userID, workspaceID, id, calendarID); respondError(w, err, bookingLinkErrors, "failed to remove conflict calendar") { + return + } + + w.WriteHeader(http.StatusNoContent) +} + +type slotsResponse struct { + Slots []time.Time `json:"slots"` +} + +// Slots serves GET /api/booking-links/{id}/slots?year=&month=: every +// bookable slot start for id within the given calendar month (#323), +// derived server-side so the eventual public page (#324) — which has no +// Session — can be told the same answer this route computes. year and +// month are required query params; month is 1-12. +func (h *BookingLinkHandler) Slots(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 + } + + year, ok := parseRequiredIntQuery(w, r, "year") + if !ok { + return + } + month, ok := parseRequiredIntQuery(w, r, "month") + if !ok { + return + } + + slots, err := h.links.DeriveSlotsForMonth(r.Context(), userID, workspaceID, id, year, time.Month(month), time.Now()) + if respondError(w, err, bookingLinkErrors, "failed to derive slots") { + return + } + + httpresponse.JSON(w, http.StatusOK, slotsResponse{Slots: slots}) +} + +func parseRequiredIntQuery(w http.ResponseWriter, r *http.Request, name string) (int, bool) { + raw := r.URL.Query().Get(name) + value, err := strconv.Atoi(raw) + if err != nil { + httpresponse.Error(w, http.StatusBadRequest, "invalid_request", name+" must be an integer") + return 0, false + } + return value, true +} diff --git a/server/internal/handlers/booking_link_slots_test.go b/server/internal/handlers/booking_link_slots_test.go new file mode 100644 index 0000000..6b3e43f --- /dev/null +++ b/server/internal/handlers/booking_link_slots_test.go @@ -0,0 +1,342 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "testing" + "time" + + "github.com/XiovV/calich/server/internal/service" +) + +// slots GETs accessToken's derived slots for linkID within (year, month), +// failing the test on anything but 200. +func (s *bookingLinkHandlerTestServer) slots(t *testing.T, accessToken string, workspaceID, linkID int64, year, month int) []time.Time { + t.Helper() + + path := "/api/booking-links/" + strconv.FormatInt(linkID, 10) + "/slots?year=" + strconv.Itoa(year) + "&month=" + strconv.Itoa(month) + resp := s.do(t, http.MethodGet, path, accessToken, workspaceID, nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 fetching slots, got %d", resp.StatusCode) + } + var body slotsResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode slots response: %v", err) + } + return body.Slots +} + +func containsSlot(slots []time.Time, at time.Time) bool { + for _, s := range slots { + if s.Equal(at) { + return true + } + } + return false +} + +// createMonFriSchedule creates a Schedule named name with an explicit +// Mon-Fri 09:00-17:00 weekly pattern in Etc/UTC — createScheduleForLink +// (booking_link_test.go) creates one with no Ranges at all, which the +// slots tests below need to actually have some. +func (s *bookingLinkHandlerTestServer) createMonFriSchedule(t *testing.T, accessToken, name string) int64 { + t.Helper() + + ranges := make([]availabilityRangeDTO, 5) + for i, weekday := range []int{1, 2, 3, 4, 5} { + ranges[i] = availabilityRangeDTO{Weekday: weekday, StartMinute: 9 * 60, EndMinute: 17 * 60} + } + + resp := s.doNoWorkspace(t, http.MethodPost, "/api/availability-schedules/", accessToken, createAvailabilityScheduleRequest{Name: name, Tzid: "Etc/UTC", Ranges: ranges}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 creating schedule, got %d", resp.StatusCode) + } + var schedule availabilityScheduleResponse + if err := json.NewDecoder(resp.Body).Decode(&schedule); err != nil { + t.Fatalf("decode create schedule response: %v", err) + } + return schedule.ID +} + +// setUpSlotsFixture registers alice, gives her a book-into Calendar, a +// Mon-Fri 09:00-17:00 Schedule in Etc/UTC, and a Booking Link with a +// 60-minute Duration, no minimum notice and a generous horizon — every +// slots test below builds on this shared shape. +func setUpSlotsFixture(t *testing.T) (s *bookingLinkHandlerTestServer, token string, userID, workspaceID, linkID int64, calendarID string) { + t.Helper() + + s = newBookingLinkHandlerTestServer(t) + token, userID, workspaceID = s.register(t, "alice") + calendarID = s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + scheduleID := s.createMonFriSchedule(t, token, "Default") + + req := defaultLinkRequest(scheduleID, calendarID) + req.MinimumNoticeMinutes = 0 + req.BookingHorizonDays = 90 + link := s.createLink(t, token, workspaceID, req) + + return s, token, userID, workspaceID, link.ID, calendarID +} + +// TestBookingLinkHandler_Slots_BusyClosesSlotFreeDoesNot covers the AC +// directly: "Busy Occurrences in the Conflict set's Calendars close slots; +// Free Events do not". +func TestBookingLinkHandler_Slots_BusyClosesSlotFreeDoesNot(t *testing.T) { + s, token, userID, workspaceID, linkID, calendarID := setUpSlotsFixture(t) + ctx := context.Background() + + // A Monday deep enough into the horizon that "now" (the test's own + // wall-clock moment) can never race past it. + monday := nextWeekday(time.Now().UTC().AddDate(0, 0, 14), time.Monday) + + if _, err := s.graph.Events.Create(ctx, userID, "evt-busy", service.EventWrite{ + CalendarID: calendarID, Title: "Busy block", Busy: true, + Start: monday.Add(10 * time.Hour), End: monday.Add(11 * time.Hour), + }); err != nil { + t.Fatalf("create busy event: %v", err) + } + if _, err := s.graph.Events.Create(ctx, userID, "evt-free", service.EventWrite{ + CalendarID: calendarID, Title: "Free block", Busy: false, + Start: monday.Add(13 * time.Hour), End: monday.Add(14 * time.Hour), + }); err != nil { + t.Fatalf("create free event: %v", err) + } + + slots := s.slots(t, token, workspaceID, linkID, monday.Year(), int(monday.Month())) + if containsSlot(slots, monday.Add(10*time.Hour)) { + t.Fatalf("expected the Busy event to close its 10:00 slot, got %v", slots) + } + if !containsSlot(slots, monday.Add(13*time.Hour)) { + t.Fatalf("expected the Free event to leave its 13:00 slot open, got %v", slots) + } +} + +// TestBookingLinkHandler_Slots_AllDayClosesOnlyWhenExplicitlyBusy covers +// the AC directly. +func TestBookingLinkHandler_Slots_AllDayClosesOnlyWhenExplicitlyBusy(t *testing.T) { + s, token, userID, workspaceID, linkID, calendarID := setUpSlotsFixture(t) + ctx := context.Background() + + monday := nextWeekday(time.Now().UTC().AddDate(0, 0, 14), time.Monday) + dayStart := time.Date(monday.Year(), monday.Month(), monday.Day(), 0, 0, 0, 0, time.UTC) + + if _, err := s.graph.Events.Create(ctx, userID, "evt-allday-free", service.EventWrite{ + CalendarID: calendarID, Title: "Holiday", AllDay: true, Busy: false, + Start: dayStart, End: dayStart.AddDate(0, 0, 1), + }); err != nil { + t.Fatalf("create free all-day event: %v", err) + } + + freeSlots := s.slots(t, token, workspaceID, linkID, monday.Year(), int(monday.Month())) + if !containsSlot(freeSlots, monday.Add(10*time.Hour)) { + t.Fatalf("expected a Free all-day event to leave every slot that day open, got %v", freeSlots) + } + + if _, err := s.graph.Events.Create(ctx, userID, "evt-allday-busy", service.EventWrite{ + CalendarID: calendarID, Title: "Offsite", AllDay: true, Busy: true, + Start: dayStart, End: dayStart.AddDate(0, 0, 1), + }); err != nil { + t.Fatalf("create busy all-day event: %v", err) + } + + busySlots := s.slots(t, token, workspaceID, linkID, monday.Year(), int(monday.Month())) + if containsSlot(busySlots, monday.Add(10*time.Hour)) { + t.Fatalf("expected an explicitly Busy all-day event to close every slot that day, got %v", busySlots) + } +} + +// TestBookingLinkHandler_Slots_RecurringBusyEventClosesEachOccurrence +// covers the recurrence-expansion half of "Busy Occurrences ... close +// slots" — a weekly recurring Event must close its slot on every Monday it +// falls in the requested month, not just its own first Occurrence. +func TestBookingLinkHandler_Slots_RecurringBusyEventClosesEachOccurrence(t *testing.T) { + s, token, userID, workspaceID, linkID, calendarID := setUpSlotsFixture(t) + ctx := context.Background() + + firstMonday := nextWeekday(time.Now().UTC().AddDate(0, 0, 14), time.Monday) + secondMonday := firstMonday.AddDate(0, 0, 7) + // Stay inside one calendar month so both Occurrences are checked + // against the same slots response. + if firstMonday.Month() != secondMonday.Month() { + firstMonday = firstMonday.AddDate(0, 0, 7) + secondMonday = firstMonday.AddDate(0, 0, 7) + } + + if _, err := s.graph.Events.Create(ctx, userID, "evt-recurring-busy", service.EventWrite{ + CalendarID: calendarID, Title: "Standing meeting", Busy: true, + Start: firstMonday.Add(10 * time.Hour), End: firstMonday.Add(11 * time.Hour), + Rrule: "FREQ=WEEKLY", Tzid: strPtr("Etc/UTC"), + }); err != nil { + t.Fatalf("create recurring busy event: %v", err) + } + + slots := s.slots(t, token, workspaceID, linkID, firstMonday.Year(), int(firstMonday.Month())) + if containsSlot(slots, firstMonday.Add(10*time.Hour)) { + t.Fatalf("expected the first Occurrence's 10:00 slot closed, got %v", slots) + } + if containsSlot(slots, secondMonday.Add(10*time.Hour)) { + t.Fatalf("expected the second Occurrence's 10:00 slot closed too, got %v", slots) + } +} + +// TestBookingLinkHandler_Slots_OverrideReplacesItsOwnOccurrencesBusyness +// covers ADR-0086's "set on a Master and independently on an Override, so +// one Occurrence of a series may differ from the rest without becoming an +// Exception" — a recurring Busy series with one Occurrence overridden to +// Free must leave that Occurrence's own slot open while every other +// Occurrence stays closed. +func TestBookingLinkHandler_Slots_OverrideReplacesItsOwnOccurrencesBusyness(t *testing.T) { + s, token, userID, workspaceID, linkID, calendarID := setUpSlotsFixture(t) + ctx := context.Background() + + firstMonday := nextWeekday(time.Now().UTC().AddDate(0, 0, 14), time.Monday) + secondMonday := firstMonday.AddDate(0, 0, 7) + if firstMonday.Month() != secondMonday.Month() { + firstMonday = firstMonday.AddDate(0, 0, 7) + secondMonday = firstMonday.AddDate(0, 0, 7) + } + recurrenceID := secondMonday.Add(10 * time.Hour) + + master, err := s.graph.Events.Create(ctx, userID, "evt-series-master", service.EventWrite{ + CalendarID: calendarID, Title: "Standing meeting", Busy: true, + Start: firstMonday.Add(10 * time.Hour), End: firstMonday.Add(11 * time.Hour), + Rrule: "FREQ=WEEKLY", Tzid: strPtr("Etc/UTC"), + }) + if err != nil { + t.Fatalf("create series master: %v", err) + } + if _, err := s.graph.Events.Create(ctx, userID, "evt-series-override", service.EventWrite{ + CalendarID: calendarID, Title: "Standing meeting (moved to free)", Busy: false, + Start: recurrenceID, End: recurrenceID.Add(time.Hour), + ParentID: &master.ID, RecurrenceID: &recurrenceID, + }); err != nil { + t.Fatalf("create override: %v", err) + } + + slots := s.slots(t, token, workspaceID, linkID, firstMonday.Year(), int(firstMonday.Month())) + if containsSlot(slots, firstMonday.Add(10*time.Hour)) { + t.Fatalf("expected the un-overridden first Occurrence's slot to stay closed, got %v", slots) + } + if !containsSlot(slots, secondMonday.Add(10*time.Hour)) { + t.Fatalf("expected the Free Override's own Occurrence slot to open up, got %v", slots) + } +} + +// TestBookingLinkHandler_Slots_TasksIncludedWhenFlagSetExcludedOtherwise +// covers the AC directly: an incomplete Task's Time block closes a slot +// only when the Tasks row is set. +func TestBookingLinkHandler_Slots_TasksIncludedWhenFlagSetExcludedOtherwise(t *testing.T) { + s, token, userID, workspaceID, linkID, calendarID := setUpSlotsFixture(t) + ctx := context.Background() + + monday := nextWeekday(time.Now().UTC().AddDate(0, 0, 14), time.Monday) + + taskLists, err := s.graph.TaskLists.ListForUser(ctx, userID, workspaceID) + if err != nil || len(taskLists) == 0 { + t.Fatalf("list task lists: %v (%d)", err, len(taskLists)) + } + task, err := s.graph.Tasks.Create(ctx, userID, workspaceID, taskLists[0].ID, "Write the report") + if err != nil { + t.Fatalf("create task: %v", err) + } + if _, err := s.graph.Tasks.SetTimeBlock(ctx, userID, workspaceID, task.ID, monday.Add(10*time.Hour), 60); err != nil { + t.Fatalf("set time block: %v", err) + } + + withTasks := s.slots(t, token, workspaceID, linkID, monday.Year(), int(monday.Month())) + if containsSlot(withTasks, monday.Add(10*time.Hour)) { + t.Fatalf("expected the incomplete Task's Time block to close its slot, got %v", withTasks) + } + + // Flip the Tasks row off. + scheduleID := getScheduleIDFromLink(t, s, token, workspaceID, linkID) + updateReq := defaultLinkRequest(scheduleID, calendarID) + updateReq.MinimumNoticeMinutes = 0 + updateReq.BookingHorizonDays = 90 + updateReq.TasksInConflictSet = false + resp := s.do(t, http.MethodPatch, "/api/booking-links/"+strconv.FormatInt(linkID, 10), token, workspaceID, updateReq) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 updating, got %d", resp.StatusCode) + } + + withoutTasks := s.slots(t, token, workspaceID, linkID, monday.Year(), int(monday.Month())) + if !containsSlot(withoutTasks, monday.Add(10*time.Hour)) { + t.Fatalf("expected the same slot open once the Tasks row is cleared, got %v", withoutTasks) + } + + // A completed Task's Time block never closes a slot, even with the + // Tasks row set again. + reEnableReq := defaultLinkRequest(scheduleID, calendarID) + reEnableReq.MinimumNoticeMinutes = 0 + reEnableReq.BookingHorizonDays = 90 + reEnableReq.TasksInConflictSet = true + reEnableResp := s.do(t, http.MethodPatch, "/api/booking-links/"+strconv.FormatInt(linkID, 10), token, workspaceID, reEnableReq) + defer reEnableResp.Body.Close() + if reEnableResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 re-enabling Tasks, got %d", reEnableResp.StatusCode) + } + if _, err := s.graph.Tasks.Complete(ctx, userID, workspaceID, task.ID); err != nil { + t.Fatalf("complete task: %v", err) + } + + afterCompletion := s.slots(t, token, workspaceID, linkID, monday.Year(), int(monday.Month())) + if !containsSlot(afterCompletion, monday.Add(10*time.Hour)) { + t.Fatalf("expected a completed Task to never close a slot, got %v", afterCompletion) + } +} + +func TestBookingLinkHandler_Slots_InvalidMonthRejected(t *testing.T) { + s, token, _, workspaceID, linkID, _ := setUpSlotsFixture(t) + + resp := s.do(t, http.MethodGet, "/api/booking-links/"+strconv.FormatInt(linkID, 10)+"/slots?year=2026&month=13", token, workspaceID, nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestBookingLinkHandler_Slots_SecondUserCannotReadFirsts(t *testing.T) { + s, aliceToken, _, workspaceID, linkID, _ := setUpSlotsFixture(t) + 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) + } + _ = aliceToken + + now := time.Now() + resp := s.do(t, http.MethodGet, "/api/booking-links/"+strconv.FormatInt(linkID, 10)+"/slots?year="+strconv.Itoa(now.Year())+"&month="+strconv.Itoa(int(now.Month())), bobToken, workspaceID, nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } +} + +// nextWeekday returns the first date on or after from whose weekday is +// weekday. +func nextWeekday(from time.Time, weekday time.Weekday) time.Time { + from = time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC) + for from.Weekday() != weekday { + from = from.AddDate(0, 0, 1) + } + return from +} + +// getScheduleIDFromLink re-reads linkID's own AvailabilityScheduleID via +// List, so a test updating other fields doesn't have to thread the +// schedule id through separately. +func getScheduleIDFromLink(t *testing.T, s *bookingLinkHandlerTestServer, accessToken string, workspaceID, linkID int64) int64 { + t.Helper() + links := s.listLinks(t, accessToken, workspaceID) + for _, l := range links { + if l.ID == linkID { + return l.AvailabilityScheduleID + } + } + t.Fatalf("booking link %d not found", linkID) + return 0 +} diff --git a/server/internal/handlers/booking_link_test.go b/server/internal/handlers/booking_link_test.go new file mode 100644 index 0000000..aceff28 --- /dev/null +++ b/server/internal/handlers/booking_link_test.go @@ -0,0 +1,820 @@ +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/config" + "github.com/XiovV/calich/server/internal/httpauth" + "github.com/XiovV/calich/server/internal/service" +) + +// bookingLinkHandlerTestServer bundles the HTTP surface Booking Links need +// (#322, ADR-0084, ADR-0087): Register (to mint real Users, each with their +// own Workspace), enough of /api/calendars to create the Calendars a link +// references, enough of /api/availability-schedules to create the Schedule +// a link references, and the /api/booking-links routes, gated by +// RequireWorkspace exactly like router.New wires them. +type bookingLinkHandlerTestServer struct { + srv *httptest.Server + graph *service.Graph + workspaces *service.WorkspaceService +} + +func newBookingLinkHandlerTestServer(t *testing.T) *bookingLinkHandlerTestServer { + t.Helper() + + cfg := apptest.Config(t) + cfg.InitialName, cfg.InitialEmail, cfg.InitialPassword = "", "", "" + cfg.EnableSignups = true + return newBookingLinkHandlerTestServerWithConfig(t, cfg) +} + +// newBookingLinkHandlerTestServerWithConfig is newBookingLinkHandlerTestServer +// for a test that needs a setting other than its defaults — the public +// booking handler tests (#324) need an SMTP-configured graph to ever see a +// link that isn't Paused, since ADR-0087 clamps every link to Paused on a +// deployment with no SMTP transport at all. +func newBookingLinkHandlerTestServerWithConfig(t *testing.T, cfg config.Config) *bookingLinkHandlerTestServer { + t.Helper() + + g := newTestGraphWithConfig(t, cfg) + + workspaces := g.Workspaces + auth := g.Auth + + authHandler := NewAuthHandler(auth, g.RateLimiter, false, false, false, true) + calendarHandler := NewCalendarHandler(g.Calendars, g.Events, g.Imports, g.Subscriptions, g.Connections, g.AttachmentStore) + scheduleHandler := NewAvailabilityScheduleHandler(g.AvailabilitySchedules) + linkHandler := NewBookingLinkHandler(g.BookingLinks) + publicHandler := NewPublicBookingHandler(g.PublicBookings, g.PublicIndex, g.PublicBookingRateLimiter) + + 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/availability-schedules", func(r chi.Router) { + r.Use(httpauth.RequireAuth(auth)) + r.Use(httpauth.RequireEnabledUser(auth)) + + r.Get("/", scheduleHandler.List) + r.Post("/", scheduleHandler.Create) + r.Delete("/{id}", scheduleHandler.Delete) + }) + + r.Route("/api/booking-links", func(r chi.Router) { + r.Use(httpauth.RequireAuth(auth)) + r.Use(httpauth.RequireEnabledUser(auth)) + r.Use(httpauth.RequireWorkspace(workspaces)) + + r.Get("/", linkHandler.List) + r.Post("/", linkHandler.Create) + r.Patch("/{id}", linkHandler.Update) + r.Delete("/{id}", linkHandler.Delete) + r.Post("/{id}/duplicate", linkHandler.Duplicate) + r.Get("/{id}/slots", linkHandler.Slots) + r.Put("/{id}/conflict-set/calendars/{calendarId}", linkHandler.AddConflictCalendar) + r.Delete("/{id}/conflict-set/calendars/{calendarId}", linkHandler.RemoveConflictCalendar) + }) + + r.Route("/api/public", func(r chi.Router) { + r.Get("/{handle}/{slug}", publicHandler.Get) + r.Get("/{handle}/{slug}/slots", publicHandler.Slots) + r.Post("/{handle}/{slug}/book", publicHandler.Book) + r.Post("/cancel-booking", publicHandler.Cancel) + r.Get("/{handle}", publicHandler.Index) + }) + + srv := httptest.NewServer(r) + t.Cleanup(srv.Close) + + return &bookingLinkHandlerTestServer{srv: srv, graph: g, workspaces: workspaces} +} + +func (s *bookingLinkHandlerTestServer) 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) + } + + user, err := s.graph.UserRepo.GetByEmail(ctx, username+"@example.com") + if err != nil { + t.Fatalf("get %s: %v", username, err) + } + workspaceList, err := s.workspaces.ListForUser(ctx, user.ID) + if err != nil || len(workspaceList) != 1 { + t.Fatalf("list workspaces for %s: %v (%d)", username, err, len(workspaceList)) + } + workspaceID = workspaceList[0].ID + + // Registration seeds the fresh Workspace with its own default + // Calendars — irrelevant noise for these tests, which reason about an + // exact Conflict set built from Calendars they create themselves. + s.clearDefaultCalendars(t, user.ID, workspaceID) + + return logged.AccessToken, user.ID, workspaceID +} + +// clearDefaultCalendars deletes every Calendar userID already owns in +// workspaceID — Register seeds a fresh Workspace with its own defaults, +// which would otherwise pollute a test's Conflict set assertions with +// Calendars it never created itself. +func (s *bookingLinkHandlerTestServer) clearDefaultCalendars(t *testing.T, userID, workspaceID int64) { + t.Helper() + ctx := context.Background() + + calendars, err := s.graph.CalendarRepo.ListByUserAndWorkspace(ctx, userID, workspaceID) + if err != nil { + t.Fatalf("list default calendars: %v", err) + } + for _, c := range calendars { + if err := s.graph.CalendarRepo.Delete(ctx, userID, c.ID); err != nil { + t.Fatalf("delete default calendar %s: %v", c.ID, err) + } + } +} + +// createCalendarForLink creates an owned Calendar named name in workspaceID +// for accessToken, returning its id. +func (s *bookingLinkHandlerTestServer) createCalendarForLink(t *testing.T, accessToken string, workspaceID int64, id, name string) string { + t.Helper() + + resp := createCalendar(t, s.srv.URL, accessToken, strconv.FormatInt(workspaceID, 10), id, name, "#12809CFF") + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 creating calendar, got %d", resp.StatusCode) + } + return id +} + +// createScheduleForLink creates an Availability Schedule for accessToken +// (private to the caller, no Workspace of its own) and returns its id. +func (s *bookingLinkHandlerTestServer) createScheduleForLink(t *testing.T, accessToken, name string) int64 { + t.Helper() + + resp := s.doNoWorkspace(t, http.MethodPost, "/api/availability-schedules/", accessToken, createAvailabilityScheduleRequest{Name: name, Tzid: "Etc/UTC"}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 creating schedule, got %d", resp.StatusCode) + } + var schedule availabilityScheduleResponse + if err := json.NewDecoder(resp.Body).Decode(&schedule); err != nil { + t.Fatalf("decode create schedule response: %v", err) + } + return schedule.ID +} + +func defaultLinkRequest(scheduleID int64, calendarID string) bookingLinkWriteRequest { + return bookingLinkWriteRequest{ + Title: "Intro call", + Slug: "intro-call", + DurationMinutes: 30, + Visibility: "public", + AvailabilityScheduleID: scheduleID, + BookIntoCalendarID: calendarID, + MinimumNoticeMinutes: 240, + BookingHorizonDays: 60, + TasksInConflictSet: true, + } +} + +func (s *bookingLinkHandlerTestServer) createLink(t *testing.T, accessToken string, workspaceID int64, req bookingLinkWriteRequest) bookingLinkResponse { + t.Helper() + + resp := s.do(t, http.MethodPost, "/api/booking-links/", accessToken, workspaceID, req) + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 creating booking link, got %d", resp.StatusCode) + } + var link bookingLinkResponse + if err := json.NewDecoder(resp.Body).Decode(&link); err != nil { + t.Fatalf("decode create booking link response: %v", err) + } + return link +} + +func (s *bookingLinkHandlerTestServer) listLinks(t *testing.T, accessToken string, workspaceID int64) []bookingLinkResponse { + t.Helper() + + resp := s.do(t, http.MethodGet, "/api/booking-links/", accessToken, workspaceID, nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 listing booking links, got %d", resp.StatusCode) + } + var list []bookingLinkResponse + if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { + t.Fatalf("decode list booking links response: %v", err) + } + return list +} + +func (s *bookingLinkHandlerTestServer) do(t *testing.T, method, path, accessToken string, workspaceID int64, body any) *http.Response { + t.Helper() + + req := s.buildRequest(t, method, path, accessToken, body) + 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 +} + +// doNoWorkspace is do without an X-Workspace-Id header, for the +// Availability Schedule routes, which carry no Workspace of their own. +func (s *bookingLinkHandlerTestServer) doNoWorkspace(t *testing.T, method, path, accessToken string, body any) *http.Response { + t.Helper() + + req := s.buildRequest(t, method, path, accessToken, body) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + return resp +} + +func (s *bookingLinkHandlerTestServer) buildRequest(t *testing.T, method, path, accessToken string, body any) *http.Request { + 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") + return req +} + +func TestBookingLinkHandler_Create_SeedsConflictSetFromOwnedCalendarsPlusBookInto(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + + bookInto := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + other := s.createCalendarForLink(t, token, workspaceID, "22222222-2222-2222-2222-222222222222", "Personal") + scheduleID := s.createScheduleForLink(t, token, "Default") + + link := s.createLink(t, token, workspaceID, defaultLinkRequest(scheduleID, bookInto)) + + if link.BookIntoCalendarID != bookInto { + t.Fatalf("expected book-into %q, got %q", bookInto, link.BookIntoCalendarID) + } + if len(link.ConflictCalendarIDs) != 2 { + t.Fatalf("expected both owned calendars seeded into the conflict set, got %v", link.ConflictCalendarIDs) + } + found := map[string]bool{} + for _, id := range link.ConflictCalendarIDs { + found[id] = true + } + if !found[bookInto] || !found[other] { + t.Fatalf("expected both %q and %q in the conflict set, got %v", bookInto, other, link.ConflictCalendarIDs) + } + if !link.TasksInConflictSet { + t.Fatalf("expected the Tasks row set by default, got false") + } + if link.Visibility != "public" { + t.Fatalf("expected visibility %q, got %q", "public", link.Visibility) + } +} + +func TestBookingLinkHandler_Create_EmptyTitleRejected(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + calendarID := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + scheduleID := s.createScheduleForLink(t, token, "Default") + + req := defaultLinkRequest(scheduleID, calendarID) + req.Title = " " + resp := s.do(t, http.MethodPost, "/api/booking-links/", token, workspaceID, req) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestBookingLinkHandler_Create_InvalidSlugRejected(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + calendarID := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + scheduleID := s.createScheduleForLink(t, token, "Default") + + req := defaultLinkRequest(scheduleID, calendarID) + req.Slug = "-not valid-" + resp := s.do(t, http.MethodPost, "/api/booking-links/", token, workspaceID, req) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestBookingLinkHandler_Create_InvalidDurationRejected(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + calendarID := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + scheduleID := s.createScheduleForLink(t, token, "Default") + + req := defaultLinkRequest(scheduleID, calendarID) + req.DurationMinutes = 0 + resp := s.do(t, http.MethodPost, "/api/booking-links/", token, workspaceID, req) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestBookingLinkHandler_Create_InvalidVisibilityRejected(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + calendarID := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + scheduleID := s.createScheduleForLink(t, token, "Default") + + req := defaultLinkRequest(scheduleID, calendarID) + req.Visibility = "hidden" + resp := s.do(t, http.MethodPost, "/api/booking-links/", token, workspaceID, req) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestBookingLinkHandler_Create_UnknownScheduleRejected(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + calendarID := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + + req := defaultLinkRequest(999999, calendarID) + resp := s.do(t, http.MethodPost, "/api/booking-links/", token, workspaceID, req) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +// TestBookingLinkHandler_Create_BookIntoRestrictedToOwnedOrEditableCalendars +// covers the AC directly: "Book-into is restricted to Calendars the User +// owns or can edit in that Workspace" — bob has no Access at all to alice's +// Calendar. +func TestBookingLinkHandler_Create_BookIntoRestrictedToOwnedOrEditableCalendars(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + aliceToken, _, aliceWorkspaceID := s.register(t, "alice") + bobToken, _, bobWorkspaceID := s.register(t, "bob") + + aliceCalendar := s.createCalendarForLink(t, aliceToken, aliceWorkspaceID, "11111111-1111-1111-1111-111111111111", "Alice's calendar") + bobScheduleID := s.createScheduleForLink(t, bobToken, "Default") + + req := defaultLinkRequest(bobScheduleID, aliceCalendar) + resp := s.do(t, http.MethodPost, "/api/booking-links/", bobToken, bobWorkspaceID, req) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 booking into a calendar with no access, got %d", resp.StatusCode) + } +} + +func TestBookingLinkHandler_Create_DuplicateSlugForSameUserIsConflict(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + calendarID := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + scheduleID := s.createScheduleForLink(t, token, "Default") + + s.createLink(t, token, workspaceID, defaultLinkRequest(scheduleID, calendarID)) + + resp := s.do(t, http.MethodPost, "/api/booking-links/", token, workspaceID, defaultLinkRequest(scheduleID, calendarID)) + defer resp.Body.Close() + if resp.StatusCode != http.StatusConflict { + t.Fatalf("expected 409, got %d", resp.StatusCode) + } +} + +// TestBookingLinkHandler_Create_SlugUniquePerUserNotInstanceWide covers the +// AC directly: "two Users may both publish intro-call". +func TestBookingLinkHandler_Create_SlugUniquePerUserNotInstanceWide(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + aliceToken, _, aliceWorkspaceID := s.register(t, "alice") + bobToken, _, bobWorkspaceID := s.register(t, "bob") + + aliceCalendar := s.createCalendarForLink(t, aliceToken, aliceWorkspaceID, "11111111-1111-1111-1111-111111111111", "Work") + aliceScheduleID := s.createScheduleForLink(t, aliceToken, "Default") + bobCalendar := s.createCalendarForLink(t, bobToken, bobWorkspaceID, "22222222-2222-2222-2222-222222222222", "Work") + bobScheduleID := s.createScheduleForLink(t, bobToken, "Default") + + s.createLink(t, aliceToken, aliceWorkspaceID, defaultLinkRequest(aliceScheduleID, aliceCalendar)) + bobsLink := s.createLink(t, bobToken, bobWorkspaceID, defaultLinkRequest(bobScheduleID, bobCalendar)) + + if bobsLink.Slug != "intro-call" { + t.Fatalf("expected bob to also get slug %q, got %q", "intro-call", bobsLink.Slug) + } +} + +// TestBookingLinkHandler_Create_ClaimsHandleOnFirstLink covers ADR-0084: +// creating a first Booking Link claims a Handle when the User has none. +func TestBookingLinkHandler_Create_ClaimsHandleOnFirstLink(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + token, userID, workspaceID := s.register(t, "alice") + calendarID := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + scheduleID := s.createScheduleForLink(t, token, "Default") + + before, err := s.graph.UserRepo.GetByID(context.Background(), userID) + if err != nil { + t.Fatalf("get user: %v", err) + } + if before.Handle != nil { + t.Fatalf("expected no Handle before creating a link, got %v", *before.Handle) + } + + s.createLink(t, token, workspaceID, defaultLinkRequest(scheduleID, calendarID)) + + after, err := s.graph.UserRepo.GetByID(context.Background(), userID) + if err != nil { + t.Fatalf("get user: %v", err) + } + if after.Handle == nil { + t.Fatalf("expected a Handle to be claimed after the first booking link") + } +} + +// TestBookingLinkHandler_Create_DoesNotOverwriteAnExistingHandle guards the +// other side of the auto-claim: a User who already has a Handle keeps it. +func TestBookingLinkHandler_Create_DoesNotOverwriteAnExistingHandle(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + token, userID, workspaceID := s.register(t, "alice") + if _, err := s.graph.Auth.UpdateHandle(context.Background(), userID, "already-chosen"); err != nil { + t.Fatalf("claim handle: %v", err) + } + calendarID := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + scheduleID := s.createScheduleForLink(t, token, "Default") + + s.createLink(t, token, workspaceID, defaultLinkRequest(scheduleID, calendarID)) + + user, err := s.graph.UserRepo.GetByID(context.Background(), userID) + if err != nil { + t.Fatalf("get user: %v", err) + } + if user.Handle == nil || *user.Handle != "already-chosen" { + t.Fatalf("expected the existing handle to survive, got %v", user.Handle) + } +} + +func TestBookingLinkHandler_Update_ReplacesFieldsAndRepinsNewBookInto(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + firstCalendar := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + secondCalendar := s.createCalendarForLink(t, token, workspaceID, "22222222-2222-2222-2222-222222222222", "Personal") + scheduleID := s.createScheduleForLink(t, token, "Default") + + link := s.createLink(t, token, workspaceID, defaultLinkRequest(scheduleID, firstCalendar)) + + updateReq := defaultLinkRequest(scheduleID, secondCalendar) + updateReq.Title = "Deep dive" + updateReq.Slug = "deep-dive" + updateReq.Visibility = "private" + resp := s.do(t, http.MethodPatch, "/api/booking-links/"+strconv.FormatInt(link.ID, 10), token, workspaceID, updateReq) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 updating, got %d", resp.StatusCode) + } + var updated bookingLinkResponse + if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil { + t.Fatalf("decode update response: %v", err) + } + if updated.Title != "Deep dive" || updated.Slug != "deep-dive" || updated.Visibility != "private" { + t.Fatalf("expected fields replaced, got %+v", updated) + } + if updated.BookIntoCalendarID != secondCalendar { + t.Fatalf("expected book-into changed to %q, got %q", secondCalendar, updated.BookIntoCalendarID) + } + found := false + for _, id := range updated.ConflictCalendarIDs { + if id == secondCalendar { + found = true + } + } + if !found { + t.Fatalf("expected the new book-into calendar re-pinned into the conflict set, got %v", updated.ConflictCalendarIDs) + } +} + +func TestBookingLinkHandler_Delete_RemovesIt(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + calendarID := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + scheduleID := s.createScheduleForLink(t, token, "Default") + link := s.createLink(t, token, workspaceID, defaultLinkRequest(scheduleID, calendarID)) + + resp := s.do(t, http.MethodDelete, "/api/booking-links/"+strconv.FormatInt(link.ID, 10), token, workspaceID, nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204, got %d", resp.StatusCode) + } + + links := s.listLinks(t, token, workspaceID) + if len(links) != 0 { + t.Fatalf("expected no links left, got %v", links) + } +} + +func TestBookingLinkHandler_Duplicate_DerivesFreshSlugAndTitleAndCopiesConflictSet(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + bookInto := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + extra := s.createCalendarForLink(t, token, workspaceID, "22222222-2222-2222-2222-222222222222", "Personal") + scheduleID := s.createScheduleForLink(t, token, "Default") + + original := s.createLink(t, token, workspaceID, defaultLinkRequest(scheduleID, bookInto)) + // Customize the conflict set before duplicating, to prove Duplicate + // copies it rather than reseeding from scratch. + removeResp := s.do(t, http.MethodDelete, "/api/booking-links/"+strconv.FormatInt(original.ID, 10)+"/conflict-set/calendars/"+extra, token, workspaceID, nil) + defer removeResp.Body.Close() + if removeResp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204 removing conflict calendar, got %d", removeResp.StatusCode) + } + + resp := s.do(t, http.MethodPost, "/api/booking-links/"+strconv.FormatInt(original.ID, 10)+"/duplicate", token, workspaceID, nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 duplicating, got %d", resp.StatusCode) + } + var duplicate bookingLinkResponse + if err := json.NewDecoder(resp.Body).Decode(&duplicate); err != nil { + t.Fatalf("decode duplicate response: %v", err) + } + + if duplicate.Slug != "intro-call-copy" { + t.Fatalf("expected slug %q, got %q", "intro-call-copy", duplicate.Slug) + } + if duplicate.Title != "Intro call (copy)" { + t.Fatalf("expected title %q, got %q", "Intro call (copy)", duplicate.Title) + } + if len(duplicate.ConflictCalendarIDs) != 1 || duplicate.ConflictCalendarIDs[0] != bookInto { + t.Fatalf("expected the duplicate to copy the customized (single-calendar) conflict set, got %v", duplicate.ConflictCalendarIDs) + } + + // Duplicating again disambiguates further. + secondResp := s.do(t, http.MethodPost, "/api/booking-links/"+strconv.FormatInt(original.ID, 10)+"/duplicate", token, workspaceID, nil) + defer secondResp.Body.Close() + var secondDuplicate bookingLinkResponse + if err := json.NewDecoder(secondResp.Body).Decode(&secondDuplicate); err != nil { + t.Fatalf("decode second duplicate response: %v", err) + } + if secondDuplicate.Slug != "intro-call-copy-2" { + t.Fatalf("expected slug %q, got %q", "intro-call-copy-2", secondDuplicate.Slug) + } +} + +func TestBookingLinkHandler_ConflictSet_AddAndRemove(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + bookInto := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + other := s.createCalendarForLink(t, token, workspaceID, "22222222-2222-2222-2222-222222222222", "Personal") + scheduleID := s.createScheduleForLink(t, token, "Default") + + link := s.createLink(t, token, workspaceID, defaultLinkRequest(scheduleID, bookInto)) + + removeResp := s.do(t, http.MethodDelete, "/api/booking-links/"+strconv.FormatInt(link.ID, 10)+"/conflict-set/calendars/"+other, token, workspaceID, nil) + defer removeResp.Body.Close() + if removeResp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204 removing, got %d", removeResp.StatusCode) + } + + addResp := s.do(t, http.MethodPut, "/api/booking-links/"+strconv.FormatInt(link.ID, 10)+"/conflict-set/calendars/"+other, token, workspaceID, nil) + defer addResp.Body.Close() + if addResp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204 adding, got %d", addResp.StatusCode) + } + + links := s.listLinks(t, token, workspaceID) + if len(links) != 1 || len(links[0].ConflictCalendarIDs) != 2 { + t.Fatalf("expected both calendars back in the conflict set, got %v", links) + } +} + +// TestBookingLinkHandler_ConflictSet_CannotRemoveBookIntoCalendar covers the +// AC directly: "the Book-into Calendar present and unremovable". +func TestBookingLinkHandler_ConflictSet_CannotRemoveBookIntoCalendar(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + bookInto := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + scheduleID := s.createScheduleForLink(t, token, "Default") + + link := s.createLink(t, token, workspaceID, defaultLinkRequest(scheduleID, bookInto)) + + resp := s.do(t, http.MethodDelete, "/api/booking-links/"+strconv.FormatInt(link.ID, 10)+"/conflict-set/calendars/"+bookInto, token, workspaceID, nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } + + links := s.listLinks(t, token, workspaceID) + found := false + for _, id := range links[0].ConflictCalendarIDs { + if id == bookInto { + found = true + } + } + if !found { + t.Fatalf("expected the book-into calendar to survive the refused removal, got %v", links[0].ConflictCalendarIDs) + } +} + +func TestBookingLinkHandler_SecondUserCannotReadUpdateOrDeleteFirsts(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + aliceToken, _, workspaceID := s.register(t, "alice") + bobToken, bobID, _ := s.register(t, "bob") + // Add bob to alice's workspace to prove privacy holds regardless of + // shared Workspace membership, the same posture task_list_test.go + // exercises for Task Lists. + 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) + } + + calendarID := s.createCalendarForLink(t, aliceToken, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + scheduleID := s.createScheduleForLink(t, aliceToken, "Default") + link := s.createLink(t, aliceToken, workspaceID, defaultLinkRequest(scheduleID, calendarID)) + + // bob's update body must reference resources *he* can legally use — a + // schedule or calendar of alice's would already be refused by + // validateWrite's own ownership checks (400), which would prove nothing + // about the link-ownership check this test is actually about. + bobCalendarID := s.createCalendarForLink(t, bobToken, workspaceID, "99999999-9999-9999-9999-999999999999", "Bob's calendar") + bobScheduleID := s.createScheduleForLink(t, bobToken, "Bob's schedule") + + updateResp := s.do(t, http.MethodPatch, "/api/booking-links/"+strconv.FormatInt(link.ID, 10), bobToken, workspaceID, defaultLinkRequest(bobScheduleID, bobCalendarID)) + defer updateResp.Body.Close() + if updateResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 updating, got %d", updateResp.StatusCode) + } + + deleteResp := s.do(t, http.MethodDelete, "/api/booking-links/"+strconv.FormatInt(link.ID, 10), bobToken, workspaceID, nil) + defer deleteResp.Body.Close() + if deleteResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 deleting, got %d", deleteResp.StatusCode) + } + + aliceLinks := s.listLinks(t, aliceToken, workspaceID) + if len(aliceLinks) != 1 || aliceLinks[0].Title != "Intro call" { + t.Fatalf("expected alice's link to survive bob's attempts untouched, got %v", aliceLinks) + } +} + +// TestBookingLinkHandler_CascadesOnWorkspaceDeletion covers booking_links. +// workspace_id's ON DELETE CASCADE (ADR-0084, mirroring task_lists'). +func TestBookingLinkHandler_CascadesOnWorkspaceDeletion(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + calendarID := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + scheduleID := s.createScheduleForLink(t, token, "Default") + s.createLink(t, token, workspaceID, defaultLinkRequest(scheduleID, calendarID)) + + 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 booking_links WHERE workspace_id = ?", workspaceID).Scan(&count); err != nil { + t.Fatalf("count booking links: %v", err) + } + if count != 0 { + t.Fatalf("expected deleting the workspace to cascade the booking link, got %d remaining", count) + } +} + +// TestBookingLinkHandler_ReferencedScheduleCannotBeDeleted closes ADR-0085's +// loop, which this ticket is what finally gives it a referencing entity to +// enforce against: "deleting a Schedule still referenced is refused". +func TestBookingLinkHandler_ReferencedScheduleCannotBeDeleted(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + calendarID := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + scheduleID := s.createScheduleForLink(t, token, "Default") + s.createLink(t, token, workspaceID, defaultLinkRequest(scheduleID, calendarID)) + + resp := s.doNoWorkspace(t, http.MethodDelete, "/api/availability-schedules/"+strconv.FormatInt(scheduleID, 10), token, nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusConflict { + t.Fatalf("expected 409 deleting a schedule still referenced by a booking link, got %d", resp.StatusCode) + } +} + +// TestBookingLinkHandler_ReferencedBookIntoCalendarCannotBeDeleted covers +// the migration's other half of the same guarantee ADR-0087 states for the +// Book-into Calendar, mirroring the Schedule test above. +func TestBookingLinkHandler_ReferencedBookIntoCalendarCannotBeDeleted(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + token, _, workspaceID := s.register(t, "alice") + calendarID := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + scheduleID := s.createScheduleForLink(t, token, "Default") + s.createLink(t, token, workspaceID, defaultLinkRequest(scheduleID, calendarID)) + + deleteReq, err := http.NewRequest(http.MethodDelete, s.srv.URL+"/api/calendars/"+calendarID, nil) + if err != nil { + t.Fatalf("build request: %v", err) + } + deleteReq.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(deleteReq) + if err != nil { + t.Fatalf("DELETE /api/calendars/%s: %v", calendarID, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusConflict { + t.Fatalf("expected 409 deleting a calendar still referenced by a booking link, got %d", resp.StatusCode) + } +} + +// TestBookingLinkHandler_CascadesOnUserDeletion covers booking_links. +// user_id's ON DELETE CASCADE (ADR-0084's "cascade on both"), isolated from +// the workspace_id cascade already covered above: alice's own account is +// deleted directly via AccountService, dispositioning her owned Calendar. +func TestBookingLinkHandler_CascadesOnUserDeletion(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + token, userID, workspaceID := s.register(t, "alice") + calendarID := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + scheduleID := s.createScheduleForLink(t, token, "Default") + s.createLink(t, token, workspaceID, defaultLinkRequest(scheduleID, calendarID)) + + ctx := context.Background() + impact, err := s.graph.Accounts.DeleteImpact(ctx, userID) + if err != nil { + t.Fatalf("delete impact: %v", err) + } + dispositions := make([]service.CalendarDisposition, len(impact.Calendars)) + for i, c := range impact.Calendars { + dispositions[i] = service.CalendarDisposition{CalendarID: c.ID, Disposition: service.DispositionDelete} + } + if err := s.graph.Accounts.Delete(ctx, userID, dispositions); err != nil { + t.Fatalf("delete account: %v", err) + } + + var count int + if err := s.graph.DB.QueryRow("SELECT COUNT(*) FROM booking_links WHERE user_id = ?", userID).Scan(&count); err != nil { + t.Fatalf("count booking links: %v", err) + } + if count != 0 { + t.Fatalf("expected deleting the user to cascade their booking link, got %d remaining", count) + } +} + +// TestBookingLinkHandler_Create_BookIntoAcceptsEditorSharedCalendar covers +// the "or can edit" half of "Book-into is restricted to Calendars the User +// owns or can edit in that Workspace": bob is only an Editor on alice's +// Calendar, not its owner, and can still book into it. +func TestBookingLinkHandler_Create_BookIntoAcceptsEditorSharedCalendar(t *testing.T) { + s := newBookingLinkHandlerTestServer(t) + aliceToken, aliceID, 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) + } + + aliceCalendar := s.createCalendarForLink(t, aliceToken, workspaceID, "11111111-1111-1111-1111-111111111111", "Shared calendar") + if _, _, err := s.graph.Calendars.Share(context.Background(), aliceID, aliceCalendar, "bob@example.com", "editor"); err != nil { + t.Fatalf("share calendar with bob: %v", err) + } + bobScheduleID := s.createScheduleForLink(t, bobToken, "Default") + + link := s.createLink(t, bobToken, workspaceID, defaultLinkRequest(bobScheduleID, aliceCalendar)) + if link.BookIntoCalendarID != aliceCalendar { + t.Fatalf("expected bob to book into alice's editor-shared calendar %q, got %q", aliceCalendar, link.BookIntoCalendarID) + } +} diff --git a/server/internal/handlers/calendar.go b/server/internal/handlers/calendar.go index 060268b..5cca800 100644 --- a/server/internal/handlers/calendar.go +++ b/server/internal/handlers/calendar.go @@ -93,16 +93,20 @@ type calendarResponse struct { // ShareCount is how many Shares the Calendar carries — whether more // than one person would be notified (#111). ShareCount int `json:"shareCount"` + // Exposed is the caller's own resolved Exposure answer (ADR-0080): + // whether this Calendar appears in their own CalDAV home-set. Read-only + // here — set via PUT .../exposure. + Exposed bool `json:"exposed"` } -func toCalendarResponse(c repository.Calendar, isOwner bool, ownerName string, shareCount int) calendarResponse { +func toCalendarResponse(c repository.Calendar, isOwner bool, ownerName string, shareCount int, exposed bool) calendarResponse { // respondWithOwnership's callers (Create, Update, Subscribe) always hand // back the caller's own Calendar, so Access is Owner — except a just- // Subscribed Calendar, whose Source clamps it to Viewer even for its // Owner (ADR-0032, ADR-0052) — ResolveAccess applied here exactly as it // is for every other caller, since c is always its own Owner's Calendar. access := service.ResolveAccess(c.UserID, c, nil) - response := calendarResponse{ID: c.ID, Name: c.Name, Color: c.Color, Access: access.String(), IsOwner: isOwner, OwnerName: ownerName, ShareCount: shareCount} + response := calendarResponse{ID: c.ID, Name: c.Name, Color: c.Color, Access: access.String(), IsOwner: isOwner, OwnerName: ownerName, ShareCount: shareCount, Exposed: exposed} if c.Source != nil { response.LastSyncedAt = c.Source.LastSyncedAt response.ErrorClass = c.Source.ErrorClass @@ -122,7 +126,7 @@ func toCalendarResponse(c repository.Calendar, isOwner bool, ownerName string, s } func toCalendarWithAccessResponse(c service.CalendarWithAccess) calendarResponse { - response := toCalendarResponse(c.Calendar, c.IsOwner, c.OwnerName, c.ShareCount) + response := toCalendarResponse(c.Calendar, c.IsOwner, c.OwnerName, c.ShareCount, c.Exposed) response.Access = c.Access.String() // c.Color is the caller's resolved display colour (ADR-0038) — their // own override if they've set one, otherwise the Calendar's own, @@ -144,7 +148,12 @@ func (h *CalendarHandler) respondWithOwnership(w http.ResponseWriter, r *http.Re httpresponse.Error(w, http.StatusInternalServerError, "internal_error", failMsg) return false } - httpresponse.JSON(w, status, toCalendarResponse(calendar, isOwner, ownerName, shareCount)) + exposed, err := h.calendars.ResolveExposure(r.Context(), userID, calendar, isOwner) + if err != nil { + httpresponse.Error(w, http.StatusInternalServerError, "internal_error", failMsg) + return false + } + httpresponse.JSON(w, status, toCalendarResponse(calendar, isOwner, ownerName, shareCount, exposed)) return true } @@ -159,6 +168,11 @@ var calendarNotFoundErrors = []errorCase{ {repository.ErrNotFound, notFound("calendar not found")}, } +// calendarDeleteErrors is Delete's own rendering: calendarNotFoundErrors +// plus ErrCalendarReferenced (#322, ADR-0087) — refusing to delete a +// Calendar a live Booking Link still writes into. +var calendarDeleteErrors = alsoHandling(calendarNotFoundErrors, errorCase{service.ErrCalendarReferenced, conflict("calendar_referenced", service.ErrCalendarReferenced.Error())}) + // workspaceMembershipErrors renders service.ErrNotWorkspaceMember as a 403 — // the caller's active Workspace, resolved from the X-Workspace-Id header, // named a Workspace they don't belong to (#155, ADR-0045). @@ -443,7 +457,7 @@ func (h *CalendarHandler) Delete(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") err := h.calendars.Delete(r.Context(), userID, id) - if respondError(w, err, calendarNotFoundErrors, "failed to delete calendar") { + if respondError(w, err, calendarDeleteErrors, "failed to delete calendar") { return } diff --git a/server/internal/handlers/calendar_exposure.go b/server/internal/handlers/calendar_exposure.go new file mode 100644 index 0000000..e9448f1 --- /dev/null +++ b/server/internal/handlers/calendar_exposure.go @@ -0,0 +1,45 @@ +// calendar_exposure.go implements CalendarHandler's Exposure endpoint +// (ADR-0080): a User's own choice about whether a Calendar appears in their +// own CalDAV home-set, on its own read/write path beside the Calendar's +// other fields — same posture as the colour override folded into Update and +// Default reminders' own endpoint (open to any User with Access, not +// Owner-only), kept separate from Update because Exposure has no +// "the Calendar's own" value for an Owner's write to land on instead: every +// principal, Owner included, only ever writes their own row. +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" +) + +type setExposureRequest struct { + Exposed bool `json:"exposed"` +} + +type exposureResponse struct { + Exposed bool `json:"exposed"` +} + +// SetExposure serves PUT /api/calendars/{id}/exposure: sets the caller's own +// Exposure choice on id — whether id appears in their own CalDAV home-set. +func (h *CalendarHandler) SetExposure(w http.ResponseWriter, r *http.Request) { + userID := httpauth.MustUserID(r.Context()) + + id := chi.URLParam(r, "id") + + req, ok := decodeJSON[setExposureRequest](w, r) + if !ok { + return + } + + if err := h.calendars.SetExposure(r.Context(), userID, id, req.Exposed); respondError(w, err, calendarNotFoundErrors, "failed to update calendar exposure") { + return + } + + httpresponse.JSON(w, http.StatusOK, exposureResponse{Exposed: req.Exposed}) +} diff --git a/server/internal/handlers/calendar_exposure_test.go b/server/internal/handlers/calendar_exposure_test.go new file mode 100644 index 0000000..3470cf7 --- /dev/null +++ b/server/internal/handlers/calendar_exposure_test.go @@ -0,0 +1,126 @@ +// calendar_exposure_test.go covers #297's REST surface for ADR-0080's +// Exposure: PUT .../exposure writes only the caller's own row, open to any +// caller with Access (Owner and accessor alike, unlike the colour override's +// Owner-vs-non-Owner split), and GET/List's resolved "exposed" field reflects +// it — plus the source-and-ownership-dependent default when no row exists. +package handlers + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/XiovV/calich/server/internal/repository" +) + +func getCalendarResponse(t *testing.T, url, accessToken string) calendarResponse { + t.Helper() + resp, err := authenticatedGet(url, accessToken) + if err != nil { + t.Fatalf("get: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var out calendarResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode: %v", err) + } + return out +} + +// TestCalendarHandler_Get_OrdinaryCalendarDefaultsExposedTrue is the sanity +// baseline: an ordinary Calendar's Owner sees it exposed by default, with no +// Exposure row ever written. +func TestCalendarHandler_Get_OrdinaryCalendarDefaultsExposedTrue(t *testing.T) { + s := newShareTestServer(t) + + got := getCalendarResponse(t, s.baseURL+"/api/calendars/"+s.calendarID, s.ownerToken) + if !got.Exposed { + t.Fatalf("expected an ordinary calendar to default to exposed, got %+v", got) + } +} + +// TestCalendarHandler_Get_LinkedCalendarOwnerDefaultsUnexposed covers +// ADR-0080's Owner-side default: absent an explicit choice, a Linked +// Calendar's own Owner sees it unexposed. +func TestCalendarHandler_Get_LinkedCalendarOwnerDefaultsUnexposed(t *testing.T) { + s := newShareTestServer(t) + s.makeLinkedCalendar(t, repository.SourceModeReadOnly) + + got := getCalendarResponse(t, s.baseURL+"/api/calendars/"+s.calendarID, s.ownerToken) + if got.Exposed { + t.Fatalf("expected a Linked Calendar to default to unexposed for its own Owner, got %+v", got) + } +} + +// TestCalendarHandler_SetExposure_OwnerSetsOwnRow covers the Owner turning +// their own Linked Calendar back on, overriding the unexposed default. +func TestCalendarHandler_SetExposure_OwnerSetsOwnRow(t *testing.T) { + s := newShareTestServer(t) + s.makeLinkedCalendar(t, repository.SourceModeReadOnly) + + resp := doJSON(t, http.MethodPut, s.baseURL+"/api/calendars/"+s.calendarID+"/exposure", s.ownerToken, setExposureRequest{Exposed: true}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var out exposureResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode: %v", err) + } + if !out.Exposed { + t.Fatalf("expected the response to echo exposed=true, got %+v", out) + } + + got := getCalendarResponse(t, s.baseURL+"/api/calendars/"+s.calendarID, s.ownerToken) + if !got.Exposed { + t.Fatalf("expected the Owner's explicit override to stick, got %+v", got) + } +} + +// TestCalendarHandler_SetExposure_ViewerSetsOwnRowIndependentOfOwner covers +// "setting writes the requesting User's own row only" (#297's acceptance +// criteria): a Viewer's own choice never touches the Owner's, in either +// direction. +func TestCalendarHandler_SetExposure_ViewerSetsOwnRowIndependentOfOwner(t *testing.T) { + s := newShareTestServer(t) + s.makeLinkedCalendar(t, repository.SourceModeReadOnly) + + shareResp := doJSON(t, http.MethodPost, s.baseURL+"/api/calendars/"+s.calendarID+"/shares", s.ownerToken, shareRequest{Email: "other@example.com", Role: repository.RoleViewer}) + shareResp.Body.Close() + + // The accessor defaults to exposed (ADR-0080); they turn it off. + setResp := doJSON(t, http.MethodPut, s.baseURL+"/api/calendars/"+s.calendarID+"/exposure", s.otherToken, setExposureRequest{Exposed: false}) + setResp.Body.Close() + if setResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", setResp.StatusCode) + } + + viewerGot := getCalendarResponse(t, s.baseURL+"/api/calendars/"+s.calendarID, s.otherToken) + if viewerGot.Exposed { + t.Fatalf("expected the Viewer's own override to stick, got %+v", viewerGot) + } + + // The Owner's own (unexposed-by-default) row is untouched by the + // Viewer's write. + ownerGot := getCalendarResponse(t, s.baseURL+"/api/calendars/"+s.calendarID, s.ownerToken) + if ownerGot.Exposed { + t.Fatalf("expected the Owner's default to stay unexposed, got %+v", ownerGot) + } +} + +// TestCalendarHandler_SetExposure_NoAccessRefused covers a caller with no +// Access at all — refused the same way every other per-Access write on a +// Calendar is (#272), 404 rather than 403 so a stranger can't distinguish +// "exists but isn't mine" from "doesn't exist". +func TestCalendarHandler_SetExposure_NoAccessRefused(t *testing.T) { + s := newShareTestServer(t) + + resp := doJSON(t, http.MethodPut, s.baseURL+"/api/calendars/"+s.calendarID+"/exposure", s.otherToken, setExposureRequest{Exposed: true}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } +} diff --git a/server/internal/handlers/calendar_set.go b/server/internal/handlers/calendar_set.go new file mode 100644 index 0000000..2dc020b --- /dev/null +++ b/server/internal/handlers/calendar_set.go @@ -0,0 +1,188 @@ +// 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/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" + "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"` + 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, 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 +// 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] = toCalendarSetWithMembersResponse(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, toCalendarSetWithMembersResponse(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) +} + +// 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 new file mode 100644 index 0000000..8e7c6c1 --- /dev/null +++ b/server/internal/handlers/calendar_set_test.go @@ -0,0 +1,608 @@ +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) + // 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)) + r.Use(httpauth.RequireWorkspace(workspaces)) + + r.Get("/", calendarSetHandler.List) + 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) + 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 +} + +// 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() + + 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) + } +} + +// 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/handlers/connection_calendars.go b/server/internal/handlers/connection_calendars.go index 6b1abae..06a03e9 100644 --- a/server/internal/handlers/connection_calendars.go +++ b/server/internal/handlers/connection_calendars.go @@ -113,7 +113,12 @@ func (h *CalendarHandler) ImportConnectionCalendars(w http.ResponseWriter, r *ht httpresponse.Error(w, http.StatusInternalServerError, "internal_error", "failed to import calendars") return } - responses[i] = toCalendarResponse(calendar, isOwner, ownerName, shareCount) + exposed, err := h.calendars.ResolveExposure(r.Context(), userID, calendar, isOwner) + if err != nil { + httpresponse.Error(w, http.StatusInternalServerError, "internal_error", "failed to import calendars") + return + } + responses[i] = toCalendarResponse(calendar, isOwner, ownerName, shareCount, exposed) } httpresponse.JSON(w, http.StatusCreated, responses) } diff --git a/server/internal/handlers/event.go b/server/internal/handlers/event.go index 9fac621..24c68f9 100644 --- a/server/internal/handlers/event.go +++ b/server/internal/handlers/event.go @@ -134,7 +134,12 @@ type eventResponse struct { // AllDay flags this Event as occupying whole dates rather than a time // range — see ADR-0017 and CONTEXT.md's All-day Event. AllDay bool `json:"allDay,omitempty"` - Rrule string `json:"rrule,omitempty"` + // Busy is iCalendar's own TRANSP (ADR-0086): whether this Event consumes + // the time it occupies. No omitempty — Free (false) is a meaningful, + // common value on an all-day Event, and omitting it would be + // indistinguishable from "absent". + Busy bool `json:"busy"` + Rrule string `json:"rrule,omitempty"` // ParentID and RecurrenceID are present only on an Override — a standalone // Event that replaces one Occurrence of its parent's series (ADR-0016). ParentID *string `json:"parentId,omitempty"` @@ -203,6 +208,7 @@ func toEventResponse(e repository.Event) eventResponse { Start: formatEventTime(e.Start, e.AllDay), End: formatEventTime(e.End, e.AllDay), AllDay: e.AllDay, + Busy: e.Busy, Rrule: e.Rrule, ParentID: e.ParentID, RecurrenceID: e.RecurrenceID, @@ -362,6 +368,14 @@ type createEventRequest struct { Start string `json:"start"` End string `json:"end"` AllDay bool `json:"allDay,omitempty"` + // Busy mirrors eventResponse.Busy (ADR-0086) — the frontend always sends + // it explicitly, deciding the create-time default (Busy for a timed + // Event, Free for an all-day one) itself. A pointer so a caller that + // omits it entirely still gets RFC 5545's own OPAQUE default (resolved + // by resolveBusyRequest) rather than Go's zero-value false (Free) — + // unlike AllDay/Rrule, false is Busy's less common state, so it cannot + // double as "absent". + Busy *bool `json:"busy,omitempty"` Rrule string `json:"rrule"` ParentID *string `json:"parentId,omitempty"` RecurrenceID *time.Time `json:"recurrenceId,omitempty"` @@ -391,6 +405,17 @@ type createEventRequest struct { AttendeeEmails []string `json:"attendeeEmails,omitempty"` } +// resolveBusyRequest resolves a create/update request's optional Busy +// pointer to a concrete value, defaulting to Busy — RFC 5545's own OPAQUE +// default — when the caller omits it entirely (ADR-0086). Shared by Create +// and Update, which carry the same field. +func resolveBusyRequest(busy *bool) bool { + if busy != nil { + return *busy + } + return true +} + // parseEventTimes converts a decoded body's start/end strings into instants, // branching on allDay (ADR-0017). Shared by create and update, which carry the // same three fields. @@ -433,6 +458,7 @@ func (h *EventHandler) Create(w http.ResponseWriter, r *http.Request) { Start: start, End: end, AllDay: req.AllDay, + Busy: resolveBusyRequest(req.Busy), Rrule: req.Rrule, ParentID: req.ParentID, RecurrenceID: req.RecurrenceID, @@ -477,6 +503,8 @@ type updateEventRequest struct { Start string `json:"start"` End string `json:"end"` AllDay bool `json:"allDay,omitempty"` + // Busy mirrors createEventRequest.Busy (ADR-0086). + Busy *bool `json:"busy,omitempty"` Rrule string `json:"rrule"` Tzid *string `json:"tzid,omitempty"` Description string `json:"description,omitempty"` @@ -511,6 +539,7 @@ func (h *EventHandler) Update(w http.ResponseWriter, r *http.Request) { Start: start, End: end, AllDay: req.AllDay, + Busy: resolveBusyRequest(req.Busy), Rrule: req.Rrule, Tzid: req.Tzid, Description: req.Description, diff --git a/server/internal/handlers/event_test.go b/server/internal/handlers/event_test.go index e18d16b..17e9663 100644 --- a/server/internal/handlers/event_test.go +++ b/server/internal/handlers/event_test.go @@ -32,6 +32,7 @@ type decodedEvent struct { Start time.Time End time.Time AllDay bool + Busy bool Rrule string ParentID *string RecurrenceID *time.Time @@ -66,6 +67,7 @@ func (d *decodedEvent) UnmarshalJSON(data []byte) error { Start: start, End: end, AllDay: wire.AllDay, + Busy: wire.Busy, Rrule: wire.Rrule, ParentID: wire.ParentID, RecurrenceID: wire.RecurrenceID, @@ -236,6 +238,86 @@ func TestEventHandler_Create_RoundTripsAllDayAsDateOnly(t *testing.T) { } } +// TestEventHandler_Create_RoundTripsBusy covers ADR-0086's wire contract: +// busy is never omitted, so a Free create must round-trip as an explicit +// false, not something indistinguishable from Busy's absence. +func TestEventHandler_Create_RoundTripsBusy(t *testing.T) { + baseURL, accessToken, calendarID := newEventTestServer(t) + + rawBody := `{"id":"22222222-2222-2222-2222-222222222222","calendarId":"` + calendarID + `","title":"Holiday","start":"2026-08-04","end":"2026-08-05","allDay":true,"busy":false}` + req, _ := http.NewRequest(http.MethodPost, baseURL+"/api/events/", bytes.NewReader([]byte(rawBody))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("post: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201, got %d", resp.StatusCode) + } + + rawResponse, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + + var wire map[string]any + if err := json.Unmarshal(rawResponse, &wire); err != nil { + t.Fatalf("decode raw: %v", err) + } + busy, ok := wire["busy"] + if !ok { + t.Fatalf("expected an explicit busy key, never an absent one, got %+v", wire) + } + if busy != false { + t.Fatalf("expected busy:false to round-trip, got %v", busy) + } + + getResp, err := authenticatedGet(baseURL+"/api/events/"+wire["id"].(string), accessToken) + if err != nil { + t.Fatalf("get: %v", err) + } + defer getResp.Body.Close() + var fetched decodedEvent + if err := json.NewDecoder(getResp.Body).Decode(&fetched); err != nil { + t.Fatalf("decode fetched: %v", err) + } + if fetched.Busy { + t.Fatalf("expected the fetched Event to stay Free, got %+v", fetched) + } +} + +// TestEventHandler_Create_OmittedBusyDefaultsToBusy covers a caller other +// than this app's own frontend (which always sends busy explicitly): a +// create body with no busy key at all must still land Busy, RFC 5545's own +// default (ADR-0086) — never Go's zero-value false a bare bool field would +// silently apply. +func TestEventHandler_Create_OmittedBusyDefaultsToBusy(t *testing.T) { + baseURL, accessToken, calendarID := newEventTestServer(t) + + rawBody := `{"id":"22222222-2222-2222-2222-222222222222","calendarId":"` + calendarID + `","title":"Standup","start":"2026-01-01T09:00:00Z","end":"2026-01-01T10:00:00Z"}` + req, _ := http.NewRequest(http.MethodPost, baseURL+"/api/events/", bytes.NewReader([]byte(rawBody))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("post: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201, got %d", resp.StatusCode) + } + + var created decodedEvent + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + t.Fatalf("decode: %v", err) + } + if !created.Busy { + t.Fatalf("expected an omitted busy key to default to Busy, got Free: %+v", created) + } +} + // A created Event with no tzid is a Floating Event (ADR-0019): the field is // absent on the wire, and start/end are always emitted as a canonical UTC // "…Z" instant regardless of the offset the client sent. @@ -628,6 +710,32 @@ func TestEventHandler_UpdateAndGet(t *testing.T) { } } +// TestEventHandler_Update_OmittedBusyDefaultsToBusy mirrors +// TestEventHandler_Create_OmittedBusyDefaultsToBusy for PATCH: a caller that +// never mentions busy at all still gets RFC 5545's own default (ADR-0086), +// not Go's zero-value false a bare bool field would silently apply. The +// helper (patchEvent) never sets busy, which is exactly the case this guards. +func TestEventHandler_Update_OmittedBusyDefaultsToBusy(t *testing.T) { + baseURL, accessToken, calendarID := newEventTestServer(t) + + createResp := createEvent(t, baseURL, accessToken, "22222222-2222-2222-2222-222222222222", calendarID, "Standup", "2026-01-01T09:00:00Z", "2026-01-01T10:00:00Z") + var created decodedEvent + json.NewDecoder(createResp.Body).Decode(&created) + + updateResp := patchEvent(t, baseURL, accessToken, created.ID, calendarID, "Renamed", "2026-01-01T11:00:00Z", "2026-01-01T12:00:00Z") + if updateResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", updateResp.StatusCode) + } + + var updated decodedEvent + if err := json.NewDecoder(updateResp.Body).Decode(&updated); err != nil { + t.Fatalf("decode: %v", err) + } + if !updated.Busy { + t.Fatalf("expected an omitted busy key to default to Busy, got Free: %+v", updated) + } +} + func TestEventHandler_Update_NotFound(t *testing.T) { baseURL, accessToken, calendarID := newEventTestServer(t) 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/handlers/public_booking.go b/server/internal/handlers/public_booking.go new file mode 100644 index 0000000..1ef7a2b --- /dev/null +++ b/server/internal/handlers/public_booking.go @@ -0,0 +1,284 @@ +// public_booking.go implements PublicBookingHandler: the stranger-facing +// GET surface behind /:handle/:slug (#324, ADR-0084, ADR-0087) — the app's +// first unauthenticated route that reads calendar data, mounted at +// /api/public and reached with no Session, no Workspace header, and no auth +// middleware at all. Rate limited per IP since it's public and reads real +// data (#324's "Public read endpoints are rate limited"). +package handlers + +import ( + "net/http" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/XiovV/calich/server/internal/caldavserver" + "github.com/XiovV/calich/server/internal/clientip" + "github.com/XiovV/calich/server/internal/httpresponse" + "github.com/XiovV/calich/server/internal/repository" + "github.com/XiovV/calich/server/internal/service" +) + +type PublicBookingHandler struct { + public *service.PublicBookingService + index *service.PublicIndexService + rateLimiter *service.PublicBookingRateLimiter +} + +func NewPublicBookingHandler(public *service.PublicBookingService, index *service.PublicIndexService, rateLimiter *service.PublicBookingRateLimiter) *PublicBookingHandler { + return &PublicBookingHandler{public: public, index: index, rateLimiter: rateLimiter} +} + +type publicBookingLinkResponse struct { + HostName string `json:"hostName"` + HostTimezone string `json:"hostTimezone"` + Title string `json:"title"` + DurationMinutes int `json:"durationMinutes"` + Location string `json:"location"` + Description string `json:"description"` + BookingHorizonDays int `json:"bookingHorizonDays"` + Paused bool `json:"paused"` +} + +func toPublicBookingLinkResponse(l service.PublicBookingLink) publicBookingLinkResponse { + return publicBookingLinkResponse{ + HostName: l.HostName, + HostTimezone: l.HostTimezone, + Title: l.Title, + DurationMinutes: l.DurationMinutes, + Location: l.Location, + Description: l.Description, + BookingHorizonDays: l.BookingHorizonDays, + Paused: l.Paused, + } +} + +// publicBookingErrors renders repository.ErrNotFound identically for an +// unknown Handle, an unknown Slug and a reserved-word Handle — the three +// are indistinguishable by design (ADR-0084), so this is the only "doesn't +// resolve" case this handler ever answers with. +var publicBookingErrors = []errorCase{ + {service.ErrInvalidMonth, badRequest(service.ErrInvalidMonth.Error())}, + {service.ErrPublicBookingRateLimitExceeded, rateLimited("too many requests, please try again later")}, + {repository.ErrNotFound, notFound("booking link not found")}, +} + +// bookErrors is publicBookingErrors widened with Book's own sentinels +// (#326, ADR-0087): a Paused link (in any of its three senses, ADR-0087), +// a missing/malformed name or email (ErrEmailRequired/ErrEmailTooLong/ +// ErrInvalidEmail are service.validateEmail's own, shared with every other +// login-identifier-shaped input in this app), and the slot race itself, +// rendered as a 409 so the frontend can tell it apart from a plain 400 and +// re-fetch slots rather than just re-showing the form. +var bookErrors = alsoHandling(publicBookingErrors, + errorCase{service.ErrBookingLinkPaused, conflict("booking_link_paused", service.ErrBookingLinkPaused.Error())}, + errorCase{service.ErrInvalidVisitorName, badRequest(service.ErrInvalidVisitorName.Error())}, + errorCase{service.ErrEmailRequired, badRequest(service.ErrEmailRequired.Error())}, + errorCase{service.ErrEmailTooLong, badRequest(service.ErrEmailTooLong.Error())}, + errorCase{service.ErrInvalidEmail, badRequest(service.ErrInvalidEmail.Error())}, + errorCase{service.ErrSlotTaken, conflict("slot_taken", service.ErrSlotTaken.Error())}, +) + +// checkRateLimit reports whether it already wrote a response (a 429), +// mirroring respondError's own bool-return convention. Every route on this +// handler calls it first, before doing any real work. +func (h *PublicBookingHandler) checkRateLimit(w http.ResponseWriter, r *http.Request) bool { + ip := clientip.From(r) + if err := h.rateLimiter.Check(r.Context(), ip); respondError(w, err, publicBookingErrors, "failed to check rate limit") { + return true + } + // Recorded regardless of what the rest of this request finds — an + // unknown handle or slug still costs a call, mirroring Register's own + // AuthRateLimiter.RecordRegisterAttempt (every call charges the bucket, + // not just a successful one), since what's throttled here is how often + // this page is loaded at all. + if err := h.rateLimiter.Record(r.Context(), ip); err != nil { + httpresponse.Error(w, http.StatusInternalServerError, "internal_error", "failed to record rate limit attempt") + return true + } + return false +} + +// Get serves GET /api/public/{handle}/{slug}: the host and offer details +// the public page renders, regardless of whether the link is Public or +// Private (ADR-0084: "a Private link is reachable by its direct URL") — +// Paused (explicit, no SMTP, or a lost Book-into Access, ADR-0087) is +// reported as a field on a 200 response rather than a distinct status, so +// the page can say plainly that it isn't accepting bookings instead of +// looking like it doesn't exist. +func (h *PublicBookingHandler) Get(w http.ResponseWriter, r *http.Request) { + if h.checkRateLimit(w, r) { + return + } + + handle := chi.URLParam(r, "handle") + slug := chi.URLParam(r, "slug") + + link, err := h.public.Get(r.Context(), handle, slug) + if respondError(w, err, publicBookingErrors, "failed to get booking link") { + return + } + + httpresponse.JSON(w, http.StatusOK, toPublicBookingLinkResponse(link)) +} + +type publicIndexLinkResponse struct { + Slug string `json:"slug"` + Title string `json:"title"` + DurationMinutes int `json:"durationMinutes"` +} + +type publicIndexResponse struct { + HostName string `json:"hostName"` + Links []publicIndexLinkResponse `json:"links"` +} + +func toPublicIndexResponse(index service.PublicIndex) publicIndexResponse { + links := make([]publicIndexLinkResponse, len(index.Links)) + for i, l := range index.Links { + links[i] = publicIndexLinkResponse{Slug: l.Slug, Title: l.Title, DurationMinutes: l.DurationMinutes} + } + return publicIndexResponse{HostName: index.HostName, Links: links} +} + +// Index serves GET /api/public/{handle}: the derived index of an owner's +// Public Booking Links (#325, ADR-0084) — always 200. An unknown Handle, a +// reserved-word Handle, and a Handle whose links are all Private, Paused, +// or filtered out by PublicIndexService's reuse of ADR-0087's Paused rule +// all render the identical empty-HostName, empty-Links shape, so this route +// cannot become a second User-enumeration door beside the one Get/Slots +// above already close. +func (h *PublicBookingHandler) Index(w http.ResponseWriter, r *http.Request) { + if h.checkRateLimit(w, r) { + return + } + + handle := chi.URLParam(r, "handle") + + index, err := h.index.Get(r.Context(), handle) + if respondError(w, err, nil, "failed to get public index") { + return + } + + httpresponse.JSON(w, http.StatusOK, toPublicIndexResponse(index)) +} + +type publicSlotsResponse struct { + Slots []time.Time `json:"slots"` +} + +// Slots serves GET /api/public/{handle}/{slug}/slots?year=&month=: every +// bookable slot start within the given calendar month (#323, #324), derived +// by the exact same server-side logic the authenticated route runs. A +// Paused link (in any of its three senses) always answers an empty list +// rather than an error. +func (h *PublicBookingHandler) Slots(w http.ResponseWriter, r *http.Request) { + if h.checkRateLimit(w, r) { + return + } + + handle := chi.URLParam(r, "handle") + slug := chi.URLParam(r, "slug") + + year, ok := parseRequiredIntQuery(w, r, "year") + if !ok { + return + } + month, ok := parseRequiredIntQuery(w, r, "month") + if !ok { + return + } + + slots, err := h.public.Slots(r.Context(), handle, slug, year, time.Month(month), time.Now()) + if respondError(w, err, publicBookingErrors, "failed to derive slots") { + return + } + + httpresponse.JSON(w, http.StatusOK, publicSlotsResponse{Slots: slots}) +} + +// bookRequest is Book's own body — the booking form's two required fields +// (ADR-0087), plus the slot start the visitor picked from Slots' own +// answer. +type bookRequest struct { + Start time.Time `json:"start"` + VisitorName string `json:"visitorName"` + VisitorEmail string `json:"visitorEmail"` +} + +type bookingResponse struct { + Start time.Time `json:"start"` + End time.Time `json:"end"` +} + +// Book serves POST /api/public/{handle}/{slug}/book: confirms req.Start +// into a Busy Event on the Book-into Calendar (#326, ADR-0087). A slot +// already taken by the time this commits — either by another visitor +// racing the same one, or because it was never really bookable — answers +// 409 slot_taken; the frontend re-fetches Slots and asks the visitor to +// pick again. +func (h *PublicBookingHandler) Book(w http.ResponseWriter, r *http.Request) { + if h.checkRateLimit(w, r) { + return + } + + handle := chi.URLParam(r, "handle") + slug := chi.URLParam(r, "slug") + + req, ok := decodeJSON[bookRequest](w, r) + if !ok { + return + } + + event, err := h.public.Book(r.Context(), handle, slug, service.BookingRequest{ + Start: req.Start, + VisitorName: req.VisitorName, + VisitorEmail: req.VisitorEmail, + }, time.Now(), caldavserver.RequestBaseURL(r)) + if respondError(w, err, bookErrors, "failed to create booking") { + return + } + + httpresponse.JSON(w, http.StatusCreated, bookingResponse{Start: event.Start, End: event.End}) +} + +// cancelBookingErrors is publicBookingErrors' own sibling for Cancel: unlike +// every other route on this handler, Cancel resolves no (handle, slug), so +// it never renders repository.ErrNotFound — an unresolvable or expired +// token is its own distinct 400, never confused with "booking link not +// found". +var cancelBookingErrors = []errorCase{ + {service.ErrPublicBookingRateLimitExceeded, rateLimited("too many requests, please try again later")}, + {service.ErrInvalidCancelToken, badRequest(service.ErrInvalidCancelToken.Error())}, + {service.ErrBookingAlreadyStarted, conflict("booking_already_started", service.ErrBookingAlreadyStarted.Error())}, +} + +// cancelBookingRequest is Cancel's own body: the signed link's token, read +// from the query string on the page it opens and posted here rather than +// acted on by a bare GET (#327, ADR-0087) — a mail client or link scanner +// prefetching the GET page never triggers the cancellation itself. +type cancelBookingRequest struct { + Token string `json:"token"` +} + +// Cancel serves POST /api/public/cancel-booking: the signed cancel link's +// own destination (#327, ADR-0087). Deletes the booking's Event, which +// emits the METHOD:CANCEL the Attendee machinery already sends and frees +// the slot immediately, and queues the host's own cancellation notice. +// Idempotent — following the link twice never errors on the second attempt. +func (h *PublicBookingHandler) Cancel(w http.ResponseWriter, r *http.Request) { + if h.checkRateLimit(w, r) { + return + } + + req, ok := decodeJSON[cancelBookingRequest](w, r) + if !ok { + return + } + + if err := h.public.Cancel(r.Context(), req.Token, time.Now()); respondError(w, err, cancelBookingErrors, "failed to cancel booking") { + return + } + + httpresponse.JSON(w, http.StatusOK, struct{}{}) +} diff --git a/server/internal/handlers/public_booking_test.go b/server/internal/handlers/public_booking_test.go new file mode 100644 index 0000000..e0bd02b --- /dev/null +++ b/server/internal/handlers/public_booking_test.go @@ -0,0 +1,671 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/url" + "strconv" + "testing" + "time" + + "github.com/XiovV/calich/server/internal/apptest" +) + +// newPublicBookingTestServer is newBookingLinkHandlerTestServerWithConfig +// over an SMTP-configured graph — the public Booking Link page (#324, +// ADR-0087) clamps every link to Paused with no SMTP transport configured, +// so a test asserting on an otherwise-bookable link needs one. +func newPublicBookingTestServer(t *testing.T) *bookingLinkHandlerTestServer { + t.Helper() + + cfg := apptest.SMTPConfig(t) + cfg.InitialName, cfg.InitialEmail, cfg.InitialPassword = "", "", "" + cfg.EnableSignups = true + return newBookingLinkHandlerTestServerWithConfig(t, cfg) +} + +// publicHandle returns userID's own Handle, claimed automatically by their +// first Booking Link (ADR-0084). +func (s *bookingLinkHandlerTestServer) publicHandle(t *testing.T, userID int64) string { + t.Helper() + + user, err := s.graph.UserRepo.GetByID(t.Context(), userID) + if err != nil { + t.Fatalf("get user: %v", err) + } + if user.Handle == nil { + t.Fatalf("expected user %d to have claimed a handle", userID) + } + return *user.Handle +} + +func (s *bookingLinkHandlerTestServer) getPublicLink(t *testing.T, handle, slug string) *http.Response { + t.Helper() + return s.doNoWorkspace(t, http.MethodGet, "/api/public/"+url.PathEscape(handle)+"/"+url.PathEscape(slug), "", nil) +} + +func (s *bookingLinkHandlerTestServer) getPublicSlots(t *testing.T, handle, slug string, year, month int) *http.Response { + t.Helper() + path := "/api/public/" + url.PathEscape(handle) + "/" + url.PathEscape(slug) + "/slots?year=" + strconv.Itoa(year) + "&month=" + strconv.Itoa(month) + return s.doNoWorkspace(t, http.MethodGet, path, "", nil) +} + +func (s *bookingLinkHandlerTestServer) getPublicIndex(t *testing.T, handle string) *http.Response { + t.Helper() + return s.doNoWorkspace(t, http.MethodGet, "/api/public/"+url.PathEscape(handle), "", nil) +} + +// setUpPublicLinkFixture registers alice on an SMTP-configured graph, gives +// her a book-into Calendar, a Mon-Fri 09:00-17:00 Schedule in Etc/UTC, and a +// Public Booking Link, returning her claimed Handle alongside the link. +func setUpPublicLinkFixture(t *testing.T) (s *bookingLinkHandlerTestServer, token, handle string, userID, workspaceID int64, link bookingLinkResponse) { + t.Helper() + + s = newPublicBookingTestServer(t) + token, userID, workspaceID = s.register(t, "alice") + calendarID := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + scheduleID := s.createMonFriSchedule(t, token, "Default") + + req := defaultLinkRequest(scheduleID, calendarID) + req.MinimumNoticeMinutes = 0 + req.BookingHorizonDays = 90 + link = s.createLink(t, token, workspaceID, req) + + handle = s.publicHandle(t, userID) + return s, token, handle, userID, workspaceID, link +} + +func TestPublicBookingHandler_Get_ReturnsHostAndLinkDetails(t *testing.T) { + s, _, handle, _, _, link := setUpPublicLinkFixture(t) + + resp := s.getPublicLink(t, handle, link.Slug) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var body publicBookingLinkResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.HostName != "alice" { + t.Fatalf("expected host name %q, got %q", "alice", body.HostName) + } + if body.HostTimezone != "Etc/UTC" { + t.Fatalf("expected host timezone %q, got %q", "Etc/UTC", body.HostTimezone) + } + if body.Title != link.Title || body.DurationMinutes != link.DurationMinutes || body.BookingHorizonDays != link.BookingHorizonDays { + t.Fatalf("expected link fields to match the created link, got %+v", body) + } + if body.Paused { + t.Fatalf("expected an SMTP-configured, Public, fully-accessible link to not be paused") + } +} + +func TestPublicBookingHandler_Get_PrivateLinkReachableDirectly(t *testing.T) { + s, token, handle, _, workspaceID, link := setUpPublicLinkFixture(t) + + updateReq := bookingLinkWriteRequest{ + Title: link.Title, Slug: link.Slug, DurationMinutes: link.DurationMinutes, + Visibility: "private", AvailabilityScheduleID: link.AvailabilityScheduleID, + BookIntoCalendarID: link.BookIntoCalendarID, MinimumNoticeMinutes: 0, BookingHorizonDays: 90, + TasksInConflictSet: link.TasksInConflictSet, + } + resp := s.do(t, http.MethodPatch, "/api/booking-links/"+strconv.FormatInt(link.ID, 10), token, workspaceID, updateReq) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 setting private, got %d", resp.StatusCode) + } + + getResp := s.getPublicLink(t, handle, link.Slug) + defer getResp.Body.Close() + if getResp.StatusCode != http.StatusOK { + t.Fatalf("expected a Private link to still be reachable by its direct URL, got %d", getResp.StatusCode) + } + + var body publicBookingLinkResponse + if err := json.NewDecoder(getResp.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.Paused { + t.Fatalf("expected a Private (not Paused) link to render as not paused") + } +} + +func TestPublicBookingHandler_Get_UnknownHandleReturns404(t *testing.T) { + s, _, _, _, _, link := setUpPublicLinkFixture(t) + + resp := s.getPublicLink(t, "no-such-handle", link.Slug) + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } +} + +func TestPublicBookingHandler_Get_UnknownSlugReturns404(t *testing.T) { + s, _, handle, _, _, _ := setUpPublicLinkFixture(t) + + resp := s.getPublicLink(t, handle, "no-such-slug") + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } +} + +// TestPublicBookingHandler_UnknownHandleAndUnknownSlugAreIndistinguishable +// covers the AC directly: an unknown Handle and an unknown Slug under a +// known Handle must render the identical response, so the namespace cannot +// enumerate Users (ADR-0084). +func TestPublicBookingHandler_UnknownHandleAndUnknownSlugAreIndistinguishable(t *testing.T) { + s, _, handle, _, _, _ := setUpPublicLinkFixture(t) + + unknownHandleResp := s.getPublicLink(t, "no-such-handle", "whatever") + defer unknownHandleResp.Body.Close() + unknownSlugResp := s.getPublicLink(t, handle, "no-such-slug") + defer unknownSlugResp.Body.Close() + + if unknownHandleResp.StatusCode != unknownSlugResp.StatusCode { + t.Fatalf("expected identical status codes, got %d and %d", unknownHandleResp.StatusCode, unknownSlugResp.StatusCode) + } + + var unknownHandleBody, unknownSlugBody map[string]any + if err := json.NewDecoder(unknownHandleResp.Body).Decode(&unknownHandleBody); err != nil { + t.Fatalf("decode unknown-handle response: %v", err) + } + if err := json.NewDecoder(unknownSlugResp.Body).Decode(&unknownSlugBody); err != nil { + t.Fatalf("decode unknown-slug response: %v", err) + } + unknownHandleError, _ := unknownHandleBody["error"].(map[string]any) + unknownSlugError, _ := unknownSlugBody["error"].(map[string]any) + if unknownHandleError["code"] != unknownSlugError["code"] || unknownHandleError["message"] != unknownSlugError["message"] { + t.Fatalf("expected identical error bodies, got %v and %v", unknownHandleBody, unknownSlugBody) + } +} + +func TestPublicBookingHandler_Get_ReservedHandleReturns404(t *testing.T) { + s, _, _, _, _, link := setUpPublicLinkFixture(t) + + resp := s.getPublicLink(t, "settings", link.Slug) + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected a reserved Handle to resolve as 404, got %d", resp.StatusCode) + } +} + +func TestPublicBookingHandler_Get_PausedWhenVisibilityExplicitlyPaused(t *testing.T) { + s, token, handle, _, workspaceID, link := setUpPublicLinkFixture(t) + + updateReq := bookingLinkWriteRequest{ + Title: link.Title, Slug: link.Slug, DurationMinutes: link.DurationMinutes, + Visibility: "paused", AvailabilityScheduleID: link.AvailabilityScheduleID, + BookIntoCalendarID: link.BookIntoCalendarID, MinimumNoticeMinutes: 0, BookingHorizonDays: 90, + TasksInConflictSet: link.TasksInConflictSet, + } + resp := s.do(t, http.MethodPatch, "/api/booking-links/"+strconv.FormatInt(link.ID, 10), token, workspaceID, updateReq) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 pausing, got %d", resp.StatusCode) + } + + getResp := s.getPublicLink(t, handle, link.Slug) + defer getResp.Body.Close() + if getResp.StatusCode != http.StatusOK { + t.Fatalf("expected a Paused link to still render, got %d", getResp.StatusCode) + } + var body publicBookingLinkResponse + if err := json.NewDecoder(getResp.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if !body.Paused { + t.Fatalf("expected paused=true") + } +} + +func TestPublicBookingHandler_Get_PausedWhenNoSMTPConfigured(t *testing.T) { + // newBookingLinkHandlerTestServer (unlike newPublicBookingTestServer) + // builds over apptest.Config's default, which has no SMTP configured. + s := newBookingLinkHandlerTestServer(t) + token, userID, workspaceID := s.register(t, "alice") + calendarID := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + scheduleID := s.createScheduleForLink(t, token, "Default") + link := s.createLink(t, token, workspaceID, defaultLinkRequest(scheduleID, calendarID)) + handle := s.publicHandle(t, userID) + + resp := s.getPublicLink(t, handle, link.Slug) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var body publicBookingLinkResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if !body.Paused { + t.Fatalf("expected a link on an instance with no SMTP configured to render as paused") + } +} + +// TestPublicBookingHandler_Get_PausedWhenBookIntoCalendarInAnotherWorkspace +// simulates the host losing Access to their own Book-into Calendar +// (ADR-0087's "the host losing Owner or Editor Access to the Book-into +// Calendar") by pointing the stored link at a Workspace the Calendar isn't +// actually in — the same mismatch BookingLinkService.validateWrite itself +// treats as "not found", here re-checked at read time rather than trusted +// from create time. +func TestPublicBookingHandler_Get_PausedWhenBookIntoCalendarInAnotherWorkspace(t *testing.T) { + s, _, handle, _, _, link := setUpPublicLinkFixture(t) + _, _, otherWorkspaceID := s.register(t, "bob") + + if _, err := s.graph.DB.Exec("UPDATE booking_links SET workspace_id = ? WHERE id = ?", otherWorkspaceID, link.ID); err != nil { + t.Fatalf("mismatch booking link's workspace: %v", err) + } + + resp := s.getPublicLink(t, handle, link.Slug) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var body publicBookingLinkResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if !body.Paused { + t.Fatalf("expected a link whose Book-into Calendar sits outside its own Workspace to render as paused") + } +} + +func TestPublicBookingHandler_Slots_ReturnsDerivedSlots(t *testing.T) { + s, _, handle, _, _, link := setUpPublicLinkFixture(t) + + // A Monday deep enough into the horizon that "now" can never race past + // it, mirroring booking_link_slots_test.go's own fixture — guards + // against a flake where "this calendar month" has no weekday left in it. + monday := nextWeekday(time.Now().UTC().AddDate(0, 0, 14), time.Monday) + resp := s.getPublicSlots(t, handle, link.Slug, monday.Year(), int(monday.Month())) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var body publicSlotsResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode slots response: %v", err) + } + if len(body.Slots) == 0 { + t.Fatalf("expected at least one derived slot from a Mon-Fri 09:00-17:00 schedule with no conflicts") + } +} + +func TestPublicBookingHandler_Slots_EmptyWhenPaused(t *testing.T) { + s, token, handle, _, workspaceID, link := setUpPublicLinkFixture(t) + + updateReq := bookingLinkWriteRequest{ + Title: link.Title, Slug: link.Slug, DurationMinutes: link.DurationMinutes, + Visibility: "paused", AvailabilityScheduleID: link.AvailabilityScheduleID, + BookIntoCalendarID: link.BookIntoCalendarID, MinimumNoticeMinutes: 0, BookingHorizonDays: 90, + TasksInConflictSet: link.TasksInConflictSet, + } + resp := s.do(t, http.MethodPatch, "/api/booking-links/"+strconv.FormatInt(link.ID, 10), token, workspaceID, updateReq) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 pausing, got %d", resp.StatusCode) + } + + now := time.Now().UTC() + slotsResp := s.getPublicSlots(t, handle, link.Slug, now.Year(), int(now.Month())) + defer slotsResp.Body.Close() + if slotsResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", slotsResp.StatusCode) + } + var body publicSlotsResponse + if err := json.NewDecoder(slotsResp.Body).Decode(&body); err != nil { + t.Fatalf("decode slots response: %v", err) + } + if len(body.Slots) != 0 { + t.Fatalf("expected no slots for a Paused link, got %v", body.Slots) + } +} + +func TestPublicBookingHandler_Slots_InvalidMonthRejected(t *testing.T) { + s, _, handle, _, _, link := setUpPublicLinkFixture(t) + + resp := s.getPublicSlots(t, handle, link.Slug, 2026, 13) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestPublicBookingHandler_RateLimited(t *testing.T) { + cfg := apptest.SMTPConfig(t) + cfg.InitialName, cfg.InitialEmail, cfg.InitialPassword = "", "", "" + cfg.EnableSignups = true + cfg.PublicBookingRateLimitPerIP = 2 + s := newBookingLinkHandlerTestServerWithConfig(t, cfg) + token, userID, workspaceID := s.register(t, "alice") + calendarID := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + scheduleID := s.createScheduleForLink(t, token, "Default") + link := s.createLink(t, token, workspaceID, defaultLinkRequest(scheduleID, calendarID)) + handle := s.publicHandle(t, userID) + + for i := 0; i < 2; i++ { + resp := s.getPublicLink(t, handle, link.Slug) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected request %d to succeed, got %d", i, resp.StatusCode) + } + } + + resp := s.getPublicLink(t, handle, link.Slug) + defer resp.Body.Close() + if resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("expected the request past the ceiling to be rate limited, got %d", resp.StatusCode) + } +} + +func (s *bookingLinkHandlerTestServer) postBook(t *testing.T, handle, slug string, body bookRequest) *http.Response { + t.Helper() + path := "/api/public/" + url.PathEscape(handle) + "/" + url.PathEscape(slug) + "/book" + return s.doNoWorkspace(t, http.MethodPost, path, "", body) +} + +// firstBookableSlotHTTP reads a slot straight off the public Slots route — +// exercising the same read path a real visitor's browser would, rather than +// reaching into the service layer. +func (s *bookingLinkHandlerTestServer) firstBookableSlotHTTP(t *testing.T, handle, slug string) time.Time { + t.Helper() + + monday := nextWeekday(time.Now().UTC().AddDate(0, 0, 14), time.Monday) + resp := s.getPublicSlots(t, handle, slug, monday.Year(), int(monday.Month())) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 fetching slots, got %d", resp.StatusCode) + } + var body publicSlotsResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode slots response: %v", err) + } + if len(body.Slots) == 0 { + t.Fatalf("expected at least one bookable slot") + } + return body.Slots[0] +} + +func TestPublicBookingHandler_Book_CreatesEventAndReturnsConfirmedTime(t *testing.T) { + s, _, handle, hostID, _, link := setUpPublicLinkFixture(t) + slot := s.firstBookableSlotHTTP(t, handle, link.Slug) + + resp := s.postBook(t, handle, link.Slug, bookRequest{Start: slot, VisitorName: "Bob Visitor", VisitorEmail: "bob@example.com"}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201, got %d", resp.StatusCode) + } + var body bookingResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode booking response: %v", err) + } + if !body.Start.Equal(slot) { + t.Fatalf("expected the confirmed start to match the requested slot %v, got %v", slot, body.Start) + } + if !body.End.Equal(slot.Add(time.Duration(link.DurationMinutes) * time.Minute)) { + t.Fatalf("expected the confirmed end to be start+duration, got %v", body.End) + } + + events, err := s.graph.Events.List(t.Context(), hostID, nil, nil) + if err != nil { + t.Fatalf("list events: %v", err) + } + found := false + for _, e := range events { + if e.Start.Equal(slot) && e.Busy && e.Title == link.Title+" — Bob Visitor" { + found = true + } + } + if !found { + t.Fatalf("expected a Busy event titled %q at %v, got %+v", link.Title+" — Bob Visitor", slot, events) + } +} + +func TestPublicBookingHandler_Book_SecondRequestForSameSlotIsRefused(t *testing.T) { + s, _, handle, _, _, link := setUpPublicLinkFixture(t) + slot := s.firstBookableSlotHTTP(t, handle, link.Slug) + + first := s.postBook(t, handle, link.Slug, bookRequest{Start: slot, VisitorName: "First Visitor", VisitorEmail: "first@example.com"}) + first.Body.Close() + if first.StatusCode != http.StatusCreated { + t.Fatalf("expected the first booking to succeed with 201, got %d", first.StatusCode) + } + + second := s.postBook(t, handle, link.Slug, bookRequest{Start: slot, VisitorName: "Second Visitor", VisitorEmail: "second@example.com"}) + defer second.Body.Close() + if second.StatusCode != http.StatusConflict { + t.Fatalf("expected the raced second booking to be refused with 409, got %d", second.StatusCode) + } + var body map[string]any + if err := json.NewDecoder(second.Body).Decode(&body); err != nil { + t.Fatalf("decode error response: %v", err) + } + errBody, _ := body["error"].(map[string]any) + if errBody["code"] != "slot_taken" { + t.Fatalf("expected error code %q, got %v", "slot_taken", errBody["code"]) + } +} + +func TestPublicBookingHandler_Book_RequiresNameAndEmail(t *testing.T) { + s, _, handle, _, _, link := setUpPublicLinkFixture(t) + slot := s.firstBookableSlotHTTP(t, handle, link.Slug) + + noName := s.postBook(t, handle, link.Slug, bookRequest{Start: slot, VisitorEmail: "bob@example.com"}) + defer noName.Body.Close() + if noName.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for a missing name, got %d", noName.StatusCode) + } + + badEmail := s.postBook(t, handle, link.Slug, bookRequest{Start: slot, VisitorName: "Bob", VisitorEmail: "not-an-email"}) + defer badEmail.Body.Close() + if badEmail.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for a malformed email, got %d", badEmail.StatusCode) + } +} + +func TestPublicBookingHandler_Book_RefusedWhenPaused(t *testing.T) { + s, token, handle, _, workspaceID, link := setUpPublicLinkFixture(t) + slot := s.firstBookableSlotHTTP(t, handle, link.Slug) + + updateReq := bookingLinkWriteRequest{ + Title: link.Title, Slug: link.Slug, DurationMinutes: link.DurationMinutes, + Visibility: "paused", AvailabilityScheduleID: link.AvailabilityScheduleID, + BookIntoCalendarID: link.BookIntoCalendarID, MinimumNoticeMinutes: 0, BookingHorizonDays: 90, + TasksInConflictSet: link.TasksInConflictSet, + } + updateResp := s.do(t, http.MethodPatch, "/api/booking-links/"+strconv.FormatInt(link.ID, 10), token, workspaceID, updateReq) + updateResp.Body.Close() + if updateResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 pausing, got %d", updateResp.StatusCode) + } + + resp := s.postBook(t, handle, link.Slug, bookRequest{Start: slot, VisitorName: "Bob", VisitorEmail: "bob@example.com"}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusConflict { + t.Fatalf("expected 409 booking a Paused link, got %d", resp.StatusCode) + } + var body map[string]any + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode error response: %v", err) + } + errBody, _ := body["error"].(map[string]any) + if errBody["code"] != "booking_link_paused" { + t.Fatalf("expected error code %q, got %v", "booking_link_paused", errBody["code"]) + } +} + +func TestPublicBookingHandler_Book_UnknownSlugReturns404(t *testing.T) { + s, _, handle, _, _, _ := setUpPublicLinkFixture(t) + + resp := s.postBook(t, handle, "no-such-slug", bookRequest{Start: time.Now().Add(48 * time.Hour), VisitorName: "Bob", VisitorEmail: "bob@example.com"}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } +} + +func TestPublicBookingHandler_Cancel_RateLimited(t *testing.T) { + cfg := apptest.SMTPConfig(t) + cfg.InitialName, cfg.InitialEmail, cfg.InitialPassword = "", "", "" + cfg.EnableSignups = true + cfg.PublicBookingRateLimitPerIP = 2 + s := newBookingLinkHandlerTestServerWithConfig(t, cfg) + + for i := 0; i < 2; i++ { + resp := s.postCancel(t, "not-a-real-token") + resp.Body.Close() + if resp.StatusCode == http.StatusTooManyRequests { + t.Fatalf("expected request %d to still be under the ceiling, got 429", i) + } + } + + resp := s.postCancel(t, "not-a-real-token") + defer resp.Body.Close() + if resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("expected the cancel endpoint to rate limit past its ceiling, got %d", resp.StatusCode) + } +} + +func (s *bookingLinkHandlerTestServer) postCancel(t *testing.T, token string) *http.Response { + t.Helper() + return s.doNoWorkspace(t, http.MethodPost, "/api/public/cancel-booking", "", cancelBookingRequest{Token: token}) +} + +// bookAndIssueCancelToken books slot over HTTP exactly like a real visitor +// would, then mints the cancel token a real confirmation mail would have +// carried — the HTTP Book response itself carries no event id to mint one +// from, mirroring what the queued BOOKING_NOTICE snapshot alone would give +// a real caller. +func (s *bookingLinkHandlerTestServer) bookAndIssueCancelToken(t *testing.T, handle, slug string, hostID int64, visitorEmail string, slot time.Time) string { + t.Helper() + token, _ := s.bookAndIssueCancelTokenWithEventID(t, handle, slug, hostID, visitorEmail, slot) + return token +} + +// bookAndIssueCancelTokenWithEventID is bookAndIssueCancelToken's own +// sibling for a test that also needs the booked Event's own id (to +// backdate it directly, rather than matching rows by their own start, +// which the sqlite driver doesn't round-trip precisely enough to compare +// against the original time.Time by value). +func (s *bookingLinkHandlerTestServer) bookAndIssueCancelTokenWithEventID(t *testing.T, handle, slug string, hostID int64, visitorEmail string, slot time.Time) (token, eventID string) { + t.Helper() + ctx := t.Context() + + resp := s.postBook(t, handle, slug, bookRequest{Start: slot, VisitorName: "Bob Visitor", VisitorEmail: visitorEmail}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 booking, got %d", resp.StatusCode) + } + + events, err := s.graph.Events.List(ctx, hostID, nil, nil) + if err != nil { + t.Fatalf("list events: %v", err) + } + for _, e := range events { + if e.Start.Equal(slot) { + eventID = e.ID + } + } + if eventID == "" { + t.Fatalf("expected to find the booked event at %v", slot) + } + + token, err = s.graph.Auth.IssueBookingCancelToken(eventID) + if err != nil { + t.Fatalf("issue cancel token: %v", err) + } + return token, eventID +} + +func TestPublicBookingHandler_Cancel_DeletesTheBookedEvent(t *testing.T) { + s, _, handle, hostID, _, link := setUpPublicLinkFixture(t) + slot := s.firstBookableSlotHTTP(t, handle, link.Slug) + token := s.bookAndIssueCancelToken(t, handle, link.Slug, hostID, "bob@example.com", slot) + + resp := s.postCancel(t, token) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 cancelling, got %d", resp.StatusCode) + } + + events, err := s.graph.Events.List(t.Context(), hostID, nil, nil) + if err != nil { + t.Fatalf("list events: %v", err) + } + for _, e := range events { + if e.Start.Equal(slot) { + t.Fatalf("expected the cancelled event to be gone, still found %+v", e) + } + } +} + +func TestPublicBookingHandler_Cancel_FreesTheSlot(t *testing.T) { + s, _, handle, hostID, _, link := setUpPublicLinkFixture(t) + slot := s.firstBookableSlotHTTP(t, handle, link.Slug) + token := s.bookAndIssueCancelToken(t, handle, link.Slug, hostID, "bob@example.com", slot) + + cancelResp := s.postCancel(t, token) + cancelResp.Body.Close() + if cancelResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 cancelling, got %d", cancelResp.StatusCode) + } + + // The freed slot is immediately bookable again. + resp := s.postBook(t, handle, link.Slug, bookRequest{Start: slot, VisitorName: "Carol", VisitorEmail: "carol@example.com"}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected the freed slot to be rebookable with 201, got %d", resp.StatusCode) + } +} + +func TestPublicBookingHandler_Cancel_IsIdempotent(t *testing.T) { + s, _, handle, hostID, _, link := setUpPublicLinkFixture(t) + slot := s.firstBookableSlotHTTP(t, handle, link.Slug) + token := s.bookAndIssueCancelToken(t, handle, link.Slug, hostID, "bob@example.com", slot) + + first := s.postCancel(t, token) + first.Body.Close() + if first.StatusCode != http.StatusOK { + t.Fatalf("expected 200 on the first cancel, got %d", first.StatusCode) + } + + second := s.postCancel(t, token) + defer second.Body.Close() + if second.StatusCode != http.StatusOK { + t.Fatalf("expected the second attempt to still succeed (idempotent), got %d", second.StatusCode) + } +} + +func TestPublicBookingHandler_Cancel_RefusesBadSignature(t *testing.T) { + s := newPublicBookingTestServer(t) + + resp := s.postCancel(t, "not-a-real-token") + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for a tampered token, got %d", resp.StatusCode) + } +} + +func TestPublicBookingHandler_Cancel_RefusesAfterEventStarted(t *testing.T) { + s, _, handle, hostID, _, link := setUpPublicLinkFixture(t) + slot := s.firstBookableSlotHTTP(t, handle, link.Slug) + token, eventID := s.bookAndIssueCancelTokenWithEventID(t, handle, link.Slug, hostID, "bob@example.com", slot) + + // Move the booked event's own start into the past (relative to the + // actual clock, not the far-future slot), as if the visitor followed a + // stale link after the meeting already happened. + past := time.Now().Add(-48 * time.Hour) + if _, err := s.graph.DB.Exec("UPDATE events SET start = ?, end = ? WHERE id = ?", past, past.Add(time.Hour), eventID); err != nil { + t.Fatalf("backdate booked event: %v", err) + } + + resp := s.postCancel(t, token) + defer resp.Body.Close() + if resp.StatusCode != http.StatusConflict { + t.Fatalf("expected 409 cancelling a started booking, got %d", resp.StatusCode) + } +} diff --git a/server/internal/handlers/public_index_test.go b/server/internal/handlers/public_index_test.go new file mode 100644 index 0000000..11dd2e7 --- /dev/null +++ b/server/internal/handlers/public_index_test.go @@ -0,0 +1,253 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" + + "github.com/XiovV/calich/server/internal/apptest" +) + +func TestPublicIndexHandler_ListsPublicLinksAcrossWorkspaces(t *testing.T) { + s, token, handle, userID, workspaceID, first := setUpPublicLinkFixture(t) + + secondWorkspace, err := s.graph.Workspaces.CreateForOwner(t.Context(), userID, "Side project") + if err != nil { + t.Fatalf("create second workspace: %v", err) + } + secondCalendar := s.createCalendarForLink(t, token, secondWorkspace.ID, "33333333-3333-3333-3333-333333333333", "Side") + secondSchedule := s.createMonFriSchedule(t, token, "Side schedule") + secondReq := defaultLinkRequest(secondSchedule, secondCalendar) + secondReq.Slug = "second-call" + secondReq.Title = "Second call" + second := s.createLink(t, token, secondWorkspace.ID, secondReq) + + resp := s.getPublicIndex(t, handle) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var body publicIndexResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.HostName != "alice" { + t.Fatalf("expected host name %q, got %q", "alice", body.HostName) + } + if len(body.Links) != 2 { + t.Fatalf("expected both workspaces' Public links unioned, got %v", body.Links) + } + slugs := map[string]bool{} + for _, l := range body.Links { + slugs[l.Slug] = true + } + if !slugs[first.Slug] || !slugs[second.Slug] { + t.Fatalf("expected both %q and %q listed, got %v", first.Slug, second.Slug, body.Links) + } + // workspaceID is unused directly but proves the fixture's own workspace + // is the one the first link lives in, distinct from the second. + _ = workspaceID +} + +func TestPublicIndexHandler_PrivateLinkExcluded(t *testing.T) { + s, token, handle, _, workspaceID, link := setUpPublicLinkFixture(t) + + updateReq := bookingLinkWriteRequest{ + Title: link.Title, Slug: link.Slug, DurationMinutes: link.DurationMinutes, + Visibility: "private", AvailabilityScheduleID: link.AvailabilityScheduleID, + BookIntoCalendarID: link.BookIntoCalendarID, MinimumNoticeMinutes: 0, BookingHorizonDays: 90, + TasksInConflictSet: link.TasksInConflictSet, + } + resp := s.do(t, http.MethodPatch, "/api/booking-links/"+strconv.FormatInt(link.ID, 10), token, workspaceID, updateReq) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 setting private, got %d", resp.StatusCode) + } + + indexResp := s.getPublicIndex(t, handle) + defer indexResp.Body.Close() + var body publicIndexResponse + if err := json.NewDecoder(indexResp.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.HostName != "" || len(body.Links) != 0 { + t.Fatalf("expected a Private-only index to render exactly like an unknown Handle, got %+v", body) + } +} + +func TestPublicIndexHandler_PausedLinkExcluded(t *testing.T) { + s, token, handle, _, workspaceID, link := setUpPublicLinkFixture(t) + + updateReq := bookingLinkWriteRequest{ + Title: link.Title, Slug: link.Slug, DurationMinutes: link.DurationMinutes, + Visibility: "paused", AvailabilityScheduleID: link.AvailabilityScheduleID, + BookIntoCalendarID: link.BookIntoCalendarID, MinimumNoticeMinutes: 0, BookingHorizonDays: 90, + TasksInConflictSet: link.TasksInConflictSet, + } + resp := s.do(t, http.MethodPatch, "/api/booking-links/"+strconv.FormatInt(link.ID, 10), token, workspaceID, updateReq) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 pausing, got %d", resp.StatusCode) + } + + indexResp := s.getPublicIndex(t, handle) + defer indexResp.Body.Close() + var body publicIndexResponse + if err := json.NewDecoder(indexResp.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(body.Links) != 0 { + t.Fatalf("expected an explicitly Paused link to be excluded, got %v", body.Links) + } +} + +// TestPublicIndexHandler_UnknownHandleAndZeroPublicLinksAreIndistinguishable +// covers the AC directly: "An unknown Handle returns the same response as a +// Handle with no Public links" (ADR-0084). A real User who claimed a Handle +// but published nothing must not be revealed by name either, or the +// namespace becomes a User enumerator by a different door than #324 closed. +func TestPublicIndexHandler_UnknownHandleAndZeroPublicLinksAreIndistinguishable(t *testing.T) { + s := newPublicBookingTestServer(t) + _, userID, _ := s.register(t, "alice") + if _, err := s.graph.Auth.UpdateHandle(t.Context(), userID, "alice"); err != nil { + t.Fatalf("claim handle: %v", err) + } + + knownResp := s.getPublicIndex(t, "alice") + defer knownResp.Body.Close() + unknownResp := s.getPublicIndex(t, "no-such-handle") + defer unknownResp.Body.Close() + + if knownResp.StatusCode != http.StatusOK || unknownResp.StatusCode != http.StatusOK { + t.Fatalf("expected both to be 200, got %d and %d", knownResp.StatusCode, unknownResp.StatusCode) + } + + var knownBody, unknownBody publicIndexResponse + if err := json.NewDecoder(knownResp.Body).Decode(&knownBody); err != nil { + t.Fatalf("decode known-handle response: %v", err) + } + if err := json.NewDecoder(unknownResp.Body).Decode(&unknownBody); err != nil { + t.Fatalf("decode unknown-handle response: %v", err) + } + if knownBody.HostName != unknownBody.HostName || len(knownBody.Links) != len(unknownBody.Links) { + t.Fatalf("expected identical responses, got %+v and %+v", knownBody, unknownBody) + } + if knownBody.HostName != "" { + t.Fatalf("expected no HostName revealed for a Handle with zero Public links, got %q", knownBody.HostName) + } +} + +func TestPublicIndexHandler_ReservedHandleReturnsEmptyIndex(t *testing.T) { + s, _, _, _, _, _ := setUpPublicLinkFixture(t) + + resp := s.getPublicIndex(t, "settings") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected a reserved Handle to resolve as 200, got %d", resp.StatusCode) + } + var body publicIndexResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.HostName != "" || len(body.Links) != 0 { + t.Fatalf("expected an empty index for a reserved Handle, got %+v", body) + } +} + +// TestPublicIndexHandler_EmptyForUserWithNoBookingLinksAtAll covers the AC +// directly: "A Handle with no Public links renders an empty index rather +// than an error" — here, a Handle claimed via Settings rather than a +// Booking Link, and no Booking Link at all. +func TestPublicIndexHandler_EmptyForUserWithNoBookingLinksAtAll(t *testing.T) { + s := newPublicBookingTestServer(t) + _, userID, _ := s.register(t, "alice") + if _, err := s.graph.Auth.UpdateHandle(t.Context(), userID, "alice"); err != nil { + t.Fatalf("claim handle: %v", err) + } + + resp := s.getPublicIndex(t, "alice") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var body publicIndexResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(body.Links) != 0 { + t.Fatalf("expected an empty index, got %v", body.Links) + } +} + +// TestPublicIndexHandler_WholeIndexPausedWhenNoSMTPConfigured covers the AC +// directly: "The whole index behaves as Paused when the instance has no +// SMTP transport configured" (ADR-0087). +func TestPublicIndexHandler_WholeIndexPausedWhenNoSMTPConfigured(t *testing.T) { + // newBookingLinkHandlerTestServer (unlike newPublicBookingTestServer) + // builds over apptest.Config's default, which has no SMTP configured. + s := newBookingLinkHandlerTestServer(t) + token, userID, workspaceID := s.register(t, "alice") + calendarID := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + scheduleID := s.createScheduleForLink(t, token, "Default") + s.createLink(t, token, workspaceID, defaultLinkRequest(scheduleID, calendarID)) + handle := s.publicHandle(t, userID) + + resp := s.getPublicIndex(t, handle) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var body publicIndexResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.HostName != "" || len(body.Links) != 0 { + t.Fatalf("expected an otherwise-public link to render as absent with no SMTP configured, got %+v", body) + } +} + +func TestPublicIndexHandler_LinkFieldsMatchTitleSlugAndDuration(t *testing.T) { + s, _, handle, _, _, link := setUpPublicLinkFixture(t) + + resp := s.getPublicIndex(t, handle) + defer resp.Body.Close() + var body publicIndexResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(body.Links) != 1 { + t.Fatalf("expected exactly one link, got %v", body.Links) + } + got := body.Links[0] + if got.Slug != link.Slug || got.Title != link.Title || got.DurationMinutes != link.DurationMinutes { + t.Fatalf("expected link fields to match the created link, got %+v", got) + } +} + +func TestPublicIndexHandler_RateLimited(t *testing.T) { + cfg := apptest.SMTPConfig(t) + cfg.InitialName, cfg.InitialEmail, cfg.InitialPassword = "", "", "" + cfg.EnableSignups = true + cfg.PublicBookingRateLimitPerIP = 2 + s := newBookingLinkHandlerTestServerWithConfig(t, cfg) + token, userID, workspaceID := s.register(t, "alice") + calendarID := s.createCalendarForLink(t, token, workspaceID, "11111111-1111-1111-1111-111111111111", "Work") + scheduleID := s.createScheduleForLink(t, token, "Default") + s.createLink(t, token, workspaceID, defaultLinkRequest(scheduleID, calendarID)) + handle := s.publicHandle(t, userID) + + for i := 0; i < 2; i++ { + resp := s.getPublicIndex(t, handle) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected request %d to succeed, got %d", i, resp.StatusCode) + } + } + + resp := s.getPublicIndex(t, handle) + defer resp.Body.Close() + if resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("expected the request past the ceiling to be rate limited, got %d", resp.StatusCode) + } +} diff --git a/server/internal/handlers/share_test.go b/server/internal/handlers/share_test.go index 50b5040..7ddf4bc 100644 --- a/server/internal/handlers/share_test.go +++ b/server/internal/handlers/share_test.go @@ -124,6 +124,7 @@ func newShareTestServer(t *testing.T) shareTestServer { r.With(httpauth.RequireWorkspace(workspaceSvc)).Get("/", calendarHandler.List) r.Get("/{id}", calendarHandler.Get) r.Patch("/{id}", calendarHandler.Update) + r.Put("/{id}/exposure", calendarHandler.SetExposure) r.Get("/{id}/shares", calendarHandler.ListShares) r.Post("/{id}/shares", calendarHandler.Share) r.Delete("/{id}/shares/{userId}", calendarHandler.RevokeShare) diff --git a/server/internal/handlers/task.go b/server/internal/handlers/task.go new file mode 100644 index 0000000..230f54b --- /dev/null +++ b/server/internal/handlers/task.go @@ -0,0 +1,379 @@ +// 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" + "time" + + "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"` + 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, + 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, 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())}, + {service.ErrInvalidTaskDuration, badRequest(service.ErrInvalidTaskDuration.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)) +} + +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 setTaskTimeBlockRequest struct { + Start time.Time `json:"start"` + DurationMinutes int `json:"durationMinutes"` +} + +// SetTimeBlock serves PUT /api/tasks/{id}/time-block: a panel row dropped on +// the hourly grid (#313, ADR-0083). Never touches the Deadline, the two axes +// being independent. +func (h *TaskHandler) SetTimeBlock(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[setTaskTimeBlockRequest](w, r) + if !ok { + return + } + + task, err := h.tasks.SetTimeBlock(r.Context(), userID, workspaceID, id, req.Start, req.DurationMinutes) + if respondError(w, err, taskErrors, "failed to set task time block") { + return + } + + httpresponse.JSON(w, http.StatusOK, toTaskResponse(task)) +} + +// ClearTimeBlock serves DELETE /api/tasks/{id}/time-block: dragging a Time +// block off the grid (a later ticket's gesture) or any other caller that +// wants the Task's block gone. Never touches the Deadline. +func (h *TaskHandler) ClearTimeBlock(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.ClearTimeBlock(r.Context(), userID, workspaceID, id) + if respondError(w, err, taskErrors, "failed to clear task time block") { + 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()) + 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_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/handlers/task_test.go b/server/internal/handlers/task_test.go new file mode 100644 index 0000000..1a7aec1 --- /dev/null +++ b/server/internal/handlers/task_test.go @@ -0,0 +1,677 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "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.Patch("/{id}/notes", taskHandler.UpdateNotes) + r.Put("/{id}/deadline", taskHandler.SetDeadline) + r.Delete("/{id}/deadline", taskHandler.ClearDeadline) + r.Put("/{id}/time-block", taskHandler.SetTimeBlock) + r.Delete("/{id}/time-block", taskHandler.ClearTimeBlock) + 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) + }) + + 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) + } +} + +// 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") + 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) + } +} + +// 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_SetAndClearTimeBlock covers #313: a panel row dropped on +// the hourly grid sets a Time block independent of the Deadline, and +// clearing it (a later ticket's own gesture, reachable here directly) +// leaves the Deadline exactly as it was either way. +func TestTaskHandler_SetAndClearTimeBlock(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, "Write the spec") + + due := time.Date(2026, time.September, 18, 0, 0, 0, 0, time.UTC) + deadlineResp := s.do(t, http.MethodPut, "/api/tasks/"+strconv.FormatInt(task.ID, 10)+"/deadline", token, workspaceID, setTaskDeadlineRequest{Due: due}) + deadlineResp.Body.Close() + if deadlineResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 setting deadline, got %d", deadlineResp.StatusCode) + } + + start := time.Date(2026, time.September, 15, 14, 0, 0, 0, time.UTC) + setResp := s.do(t, http.MethodPut, "/api/tasks/"+strconv.FormatInt(task.ID, 10)+"/time-block", token, workspaceID, setTaskTimeBlockRequest{Start: start, DurationMinutes: 60}) + defer setResp.Body.Close() + if setResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 setting time block, got %d", setResp.StatusCode) + } + var withBlock taskResponse + if err := json.NewDecoder(setResp.Body).Decode(&withBlock); err != nil { + t.Fatalf("decode set time block response: %v", err) + } + if withBlock.Start == nil || !withBlock.Start.Equal(start) { + t.Fatalf("expected start %v, got %v", start, withBlock.Start) + } + if withBlock.DurationMinutes == nil || *withBlock.DurationMinutes != 60 { + t.Fatalf("expected duration 60, got %v", withBlock.DurationMinutes) + } + if withBlock.Due == nil || !withBlock.Due.Equal(due) { + t.Fatalf("expected deadline untouched at %v, got %v", due, withBlock.Due) + } + + clearResp := s.do(t, http.MethodDelete, "/api/tasks/"+strconv.FormatInt(task.ID, 10)+"/time-block", token, workspaceID, nil) + defer clearResp.Body.Close() + if clearResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 clearing time block, got %d", clearResp.StatusCode) + } + var cleared taskResponse + if err := json.NewDecoder(clearResp.Body).Decode(&cleared); err != nil { + t.Fatalf("decode clear time block response: %v", err) + } + if cleared.Start != nil || cleared.DurationMinutes != nil { + t.Fatalf("expected time block cleared, got start %v duration %v", cleared.Start, cleared.DurationMinutes) + } + if cleared.Due == nil || !cleared.Due.Equal(due) { + t.Fatalf("expected deadline still untouched at %v, got %v", due, cleared.Due) + } +} + +// TestTaskHandler_SetTimeBlock_RejectsNonPositiveDuration covers #313's +// input guard: a drop can never itself produce a non-positive duration, but +// the endpoint refuses one rather than storing it. +func TestTaskHandler_SetTimeBlock_RejectsNonPositiveDuration(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, "Write the spec") + + resp := s.do(t, http.MethodPut, "/api/tasks/"+strconv.FormatInt(task.ID, 10)+"/time-block", token, workspaceID, setTaskTimeBlockRequest{Start: time.Now(), DurationMinutes: 0}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for a non-positive duration, got %d", resp.StatusCode) + } +} + +// 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/icalendar/ical_attach_test.go b/server/internal/icalendar/ical_attach_test.go index 0c91b1c..dc24e1f 100644 --- a/server/internal/icalendar/ical_attach_test.go +++ b/server/internal/icalendar/ical_attach_test.go @@ -171,8 +171,10 @@ func TestSeriesToICal_CalDAVTarget_UnaffectedByInlineAmendment(t *testing.T) { // Attachments) CalDAV ETag to a literal hash: ADR-0041 explicitly promises // "CalDAV output is unchanged by this ADR — no ETags churn", unlike // ADR-0031's own VTIMEZONE rollout, which changed every ETag once on -// purpose. If this test ever needs its literal updated, that is this -// promise breaking and should be treated as a bug, not a rebase. +// purpose. If this test ever needs its literal updated for a reason other +// than a deliberate codec change to a *new* feature's own ADR — like +// ADR-0086's TRANSP, which this literal was last bumped for — that is +// ADR-0041's promise breaking and should be treated as a bug, not a rebase. func TestSeriesToICal_CalDAVTarget_ETagPinned(t *testing.T) { master := repository.Event{ ID: "evt-1", @@ -191,7 +193,7 @@ func TestSeriesToICal_CalDAVTarget_ETagPinned(t *testing.T) { t.Fatalf("CalendarETag: %v", err) } - const wantETag = "e0eeb8bd28478e5cd2bbf3371973a746f3f017ad9b63453f764d2fa19696da82" + const wantETag = "1e985d8b7d5065192c408d12600f91dd6e4e66fa8f9d9d0d8bc059f9afdbe6a1" if etag != wantETag { t.Fatalf("ETag changed: got %s, want %s (ADR-0041 promises CalDAV output is unchanged)", etag, wantETag) } diff --git a/server/internal/icalendar/ical_decode.go b/server/internal/icalendar/ical_decode.go index 154bc97..81c0d17 100644 --- a/server/internal/icalendar/ical_decode.go +++ b/server/internal/icalendar/ical_decode.go @@ -27,6 +27,12 @@ type ParsedEvent struct { Title, Description, Location, URL string Start, End time.Time AllDay bool + // Busy decodes TRANSP (ADR-0086): true unless the property is present + // and literally TRANSPARENT — RFC 5545's own default (OPAQUE) for a + // VEVENT carrying no TRANSP at all, honoured verbatim rather than + // re-derived from AllDay. An imported or CalDAV-written all-day Event + // marked OPAQUE stays Busy. + Busy bool Tzid *string Reminders []repository.Reminder // Color is the exact hex this VEVENT's COLOR keyword decodes to, nil if @@ -184,12 +190,27 @@ func parseVEvent(v ical.Event, isOverride bool) (ParsedEvent, string, error) { Start: start, End: end, AllDay: allDay, + Busy: parseEventBusy(v), Tzid: tzid, Reminders: reminders, Color: parseEventColor(v), }, rrule, nil } +// parseEventBusy decodes v's TRANSP property (ADR-0086): Free only when it is +// present and literally "TRANSPARENT" — anything else, including its +// absence, is Busy, matching RFC 5545's own OPAQUE default. Never re-derived +// from AllDay: the all-day-defaults-Free departure is this app's own create +// path, not something an imported or CalDAV-written Event's stated value is +// ever overridden by. +func parseEventBusy(v ical.Event) bool { + prop := v.Props.Get(ical.PropTransparency) + if prop == nil { + return true + } + return prop.Value != "TRANSPARENT" +} + // parseEventEnd resolves when v ends, in RFC 5545's own order of // preference: DTEND if it carries one, else DTSTART plus DURATION, else the // default the spec gives DTSTART's value type — one day for a date-valued diff --git a/server/internal/icalendar/ical_decode_test.go b/server/internal/icalendar/ical_decode_test.go index 73afe7c..ffef85a 100644 --- a/server/internal/icalendar/ical_decode_test.go +++ b/server/internal/icalendar/ical_decode_test.go @@ -439,6 +439,57 @@ func TestParseCalendarObject_UnrecognizedColorKeyword_IsDropped(t *testing.T) { } } +// TestParseCalendarObject_Busy_RoundTrips covers ADR-0086: both TRANSP +// values round-trip through decode losslessly. +func TestParseCalendarObject_Busy_RoundTrips(t *testing.T) { + busy := repository.Event{ + ID: "evt-1", + Title: "Meeting", + Busy: true, + Start: time.Date(2026, 7, 1, 15, 0, 0, 0, time.UTC), + End: time.Date(2026, 7, 1, 16, 0, 0, 0, time.UTC), + CreatedAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), + } + if !mustParse(t, busy, nil).Master.Busy { + t.Fatalf("expected TRANSP:OPAQUE to decode Busy") + } + + free := busy + free.Busy = false + if mustParse(t, free, nil).Master.Busy { + t.Fatalf("expected TRANSP:TRANSPARENT to decode Free") + } +} + +// TestParseCalendarObject_NoTranspProperty_DefaultsToBusy covers RFC 5545's +// own OPAQUE default (ADR-0086): a VEVENT with no TRANSP at all — including +// an all-day one — decodes Busy, never re-derived to Free from AllDay. The +// all-day-defaults-Free departure belongs to this app's own create path, +// never to what a foreign VEVENT's absent TRANSP is read as. +func TestParseCalendarObject_NoTranspProperty_DefaultsToBusy(t *testing.T) { + master := repository.Event{ + ID: "evt-1", + Title: "Holiday", + AllDay: true, + Start: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), + End: time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC), + CreatedAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), + } + cal, _, err := SeriesToICal(master, nil, SerializationTarget{}) + if err != nil { + t.Fatalf("seriesToICal: %v", err) + } + cal.Children[0].Props.Del(ical.PropTransparency) + + parsed, err := ParseCalendarObject(cal) + if err != nil { + t.Fatalf("parseCalendarObject: %v", err) + } + if !parsed.Master.Busy { + t.Fatalf("expected an all-day VEVENT with no TRANSP to decode Busy, got Free") + } +} + func TestParseCalendarObject_UnmodeledValarmAction_IsDropped(t *testing.T) { master := repository.Event{ ID: "evt-1", diff --git a/server/internal/icalendar/ical_test.go b/server/internal/icalendar/ical_test.go index 78dea65..7ebca3e 100644 --- a/server/internal/icalendar/ical_test.go +++ b/server/internal/icalendar/ical_test.go @@ -301,6 +301,64 @@ func TestSeriesToICal_ColorOnOverrideOnly(t *testing.T) { } } +// TestSeriesToICal_Busy_EmitsTransp covers ADR-0086: TRANSP is emitted +// unconditionally, OPAQUE for a Busy Event and TRANSPARENT for a Free one — +// unlike COLOR, which is omitted when inherited, TRANSP always has a +// concrete value to state. +func TestSeriesToICal_Busy_EmitsTransp(t *testing.T) { + busy := repository.Event{ + ID: "evt-1", + Title: "Standup", + Busy: true, + Start: time.Date(2026, 7, 1, 15, 0, 0, 0, time.UTC), + End: time.Date(2026, 7, 1, 16, 0, 0, 0, time.UTC), + CreatedAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), + } + if !strings.Contains(mustEncode(t, busy, nil), "TRANSP:OPAQUE") { + t.Fatalf("expected TRANSP:OPAQUE for a Busy Event, got:\n%s", mustEncode(t, busy, nil)) + } + + free := busy + free.Busy = false + if !strings.Contains(mustEncode(t, free, nil), "TRANSP:TRANSPARENT") { + t.Fatalf("expected TRANSP:TRANSPARENT for a Free Event, got:\n%s", mustEncode(t, free, nil)) + } +} + +// TestSeriesToICal_Busy_IndependentOnOverride covers ADR-0086's "settable +// independently on an Override" — a Free Override under a Busy Master emits +// its own TRANSP:TRANSPARENT alongside the Master's TRANSP:OPAQUE. +func TestSeriesToICal_Busy_IndependentOnOverride(t *testing.T) { + recurrenceID := time.Date(2026, 7, 8, 15, 0, 0, 0, time.UTC) + master := repository.Event{ + ID: "evt-1", + Title: "Meeting", + Busy: true, + Rrule: "FREQ=WEEKLY", + Start: time.Date(2026, 7, 1, 15, 0, 0, 0, time.UTC), + End: time.Date(2026, 7, 1, 16, 0, 0, 0, time.UTC), + CreatedAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), + } + override := repository.Event{ + ID: "evt-1-override", + Title: "Meeting", + Busy: false, + RecurrenceID: &recurrenceID, + Start: recurrenceID, + End: recurrenceID.Add(time.Hour), + CreatedAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), + } + + body := mustEncode(t, master, []repository.Event{override}) + + if strings.Count(body, "TRANSP:OPAQUE") != 1 { + t.Fatalf("expected exactly one TRANSP:OPAQUE (the Master), got:\n%s", body) + } + if strings.Count(body, "TRANSP:TRANSPARENT") != 1 { + t.Fatalf("expected exactly one TRANSP:TRANSPARENT (the Override), got:\n%s", body) + } +} + func TestSeriesToICal_Reminders_SerializeAsValarm(t *testing.T) { master := repository.Event{ ID: "evt-1", diff --git a/server/internal/icalendar/vevent.go b/server/internal/icalendar/vevent.go index 1e54936..35cb24f 100644 --- a/server/internal/icalendar/vevent.go +++ b/server/internal/icalendar/vevent.go @@ -49,6 +49,7 @@ func buildVEvent(e repository.Event, uid string, recurrenceID *time.Time, recurr } v.Props.SetText(ical.PropColor, nearestCSS3Keyword(r, g, b)) } + v.Props.SetText(ical.PropTransparency, transpValue(e.Busy)) // recurrenceID nil: RECURRENCE-ID (below) is built separately, against // recurrenceIDAnchor rather than e's own AllDay/Tzid. @@ -81,6 +82,15 @@ func buildVEvent(e repository.Event, uid string, recurrenceID *time.Time, recurr return v, nil } +// transpValue renders busy as TRANSP's own text vocabulary (ADR-0086): +// OPAQUE for Busy, TRANSPARENT for Free. +func transpValue(busy bool) string { + if busy { + return "OPAQUE" + } + return "TRANSPARENT" +} + // appendOrganizerProp adds ORGANIZER naming e's Organizer — the User named // by CreatedBy — with CN=CreatedByName and that User's own Email as the // mailto address, the CalDAV/Calendar-file form ADR-0059 defines (the 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/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/availability_schedule.go b/server/internal/repository/availability_schedule.go new file mode 100644 index 0000000..d55232d --- /dev/null +++ b/server/internal/repository/availability_schedule.go @@ -0,0 +1,251 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +// ErrScheduleReferenced is returned by Delete when id is still the +// AvailabilityScheduleID of some Booking Link (#322, ADR-0085) — deleting a +// Schedule still in use is refused, and the caller must repoint or delete +// that Booking Link first. +var ErrScheduleReferenced = errors.New("availability schedule still referenced by a booking link") + +// AvailabilityRange is one weekly time range on an Availability Schedule +// (ADR-0085): a weekday (0-6, Sunday-Saturday, matching users.week_start's +// own convention) plus a start/end minute-of-day pair. Several per weekday +// are legal, so a split day is representable. +type AvailabilityRange struct { + ID int64 + ScheduleID int64 + Weekday int + StartMinute int + EndMinute int +} + +// AvailabilitySchedule is an Availability Schedule (ADR-0085): a named +// weekly pattern of time ranges belonging to one User, carrying its own IANA +// timezone. Deliberately not scoped to a Workspace — unlike a Calendar Set +// or a Task List, a Schedule is reusable across every Workspace a Booking +// Link of its owner's might write into. +type AvailabilitySchedule struct { + ID int64 + UserID int64 + Name string + Tzid string + CreatedAt time.Time + Ranges []AvailabilityRange +} + +type AvailabilityScheduleRepository struct { + db DBTX +} + +func NewAvailabilityScheduleRepository(db *sql.DB) *AvailabilityScheduleRepository { + return &AvailabilityScheduleRepository{db: db} +} + +// WithTx returns a copy of the repository bound to tx, for use inside +// repository.WithTx to make Create/Update's schedule-plus-ranges writes +// atomic (ADR-0018), mirroring TaskListRepository's own WithTx. +func (r *AvailabilityScheduleRepository) WithTx(tx *sql.Tx) *AvailabilityScheduleRepository { + return &AvailabilityScheduleRepository{db: tx} +} + +const availabilityScheduleColumns = `id, user_id, name, tzid, created_at` + +// CreateSchedule inserts the schedule row alone, named name with timezone +// tzid, owned by userID. Its ranges are a separate insert (InsertRanges), +// left to the caller to run in the same transaction (AvailabilityScheduleService.Create +// does exactly this) — the two are never observed apart from each other in +// practice, but the split keeps this repository's methods single-table, the +// same shape as every other repository here. +func (r *AvailabilityScheduleRepository) CreateSchedule(ctx context.Context, userID int64, name, tzid string) (AvailabilitySchedule, error) { + res, err := r.db.ExecContext(ctx, + `INSERT INTO availability_schedules (user_id, name, tzid) VALUES (?, ?, ?)`, + userID, name, tzid, + ) + if err != nil { + return AvailabilitySchedule{}, fmt.Errorf("insert availability schedule: %w", err) + } + + id, err := res.LastInsertId() + if err != nil { + return AvailabilitySchedule{}, fmt.Errorf("get inserted availability schedule id: %w", err) + } + + return r.GetByID(ctx, id, userID) +} + +// InsertRanges appends ranges to scheduleID, in the order given. A no-op +// against an empty slice — an empty Schedule is legal (ADR-0085). +func (r *AvailabilityScheduleRepository) InsertRanges(ctx context.Context, scheduleID int64, ranges []AvailabilityRange) error { + for _, rng := range ranges { + if _, err := r.db.ExecContext(ctx, + `INSERT INTO availability_schedule_ranges (schedule_id, weekday, start_minute, end_minute) VALUES (?, ?, ?, ?)`, + scheduleID, rng.Weekday, rng.StartMinute, rng.EndMinute, + ); err != nil { + return fmt.Errorf("insert availability schedule range: %w", err) + } + } + return nil +} + +// DeleteRanges removes every range belonging to scheduleID — Update's first +// step before InsertRanges lays the replacement set back down, run in the +// same transaction as both (AvailabilityScheduleService.Update). +func (r *AvailabilityScheduleRepository) DeleteRanges(ctx context.Context, scheduleID int64) error { + if _, err := r.db.ExecContext(ctx, `DELETE FROM availability_schedule_ranges WHERE schedule_id = ?`, scheduleID); err != nil { + return fmt.Errorf("delete availability schedule ranges: %w", err) + } + return nil +} + +// UpdateFields renames id and/or changes its timezone, scoped to userID like +// GetByID. +func (r *AvailabilityScheduleRepository) UpdateFields(ctx context.Context, id, userID int64, name, tzid string) error { + res, err := r.db.ExecContext(ctx, + `UPDATE availability_schedules SET name = ?, tzid = ? WHERE id = ? AND user_id = ?`, + name, tzid, id, userID, + ) + if err != nil { + return fmt.Errorf("update availability schedule: %w", err) + } + return requireAffected(res) +} + +// GetByID returns the Schedule identified by id, scoped to userID — a +// Schedule belonging to another User is ErrNotFound rather than any error +// naming the mismatch, the same posture as TaskListRepository.GetByID. +// Ranges are loaded and attached in Weekday, then start_minute order. +func (r *AvailabilityScheduleRepository) GetByID(ctx context.Context, id, userID int64) (AvailabilitySchedule, error) { + row := r.db.QueryRowContext(ctx, + `SELECT `+availabilityScheduleColumns+` FROM availability_schedules WHERE id = ? AND user_id = ?`, + id, userID, + ) + schedule, err := scanAvailabilityScheduleRow(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return AvailabilitySchedule{}, ErrNotFound + } + return AvailabilitySchedule{}, fmt.Errorf("scan availability schedule: %w", err) + } + + ranges, err := r.listRanges(ctx, []int64{id}) + if err != nil { + return AvailabilitySchedule{}, err + } + schedule.Ranges = ranges[id] + + return schedule, nil +} + +// ListForUser returns every Schedule userID owns, ordered by when it was +// created, each carrying its own Ranges. +func (r *AvailabilityScheduleRepository) ListForUser(ctx context.Context, userID int64) ([]AvailabilitySchedule, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT `+availabilityScheduleColumns+` FROM availability_schedules WHERE user_id = ? ORDER BY created_at, id`, + userID, + ) + if err != nil { + return nil, fmt.Errorf("list availability schedules: %w", err) + } + schedules, err := collectRows(rows, scanAvailabilityScheduleRow) + if err != nil { + return nil, err + } + + ids := make([]int64, len(schedules)) + for i, s := range schedules { + ids[i] = s.ID + } + rangesByID, err := r.listRanges(ctx, ids) + if err != nil { + return nil, err + } + for i := range schedules { + schedules[i].Ranges = rangesByID[schedules[i].ID] + } + + return schedules, nil +} + +// CountForUser returns how many Schedules userID owns — ensureDefault's +// lazy-creation check (ADR-0085): zero means none has been seeded yet. +func (r *AvailabilityScheduleRepository) CountForUser(ctx context.Context, userID int64) (int, error) { + var count int + if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM availability_schedules WHERE user_id = ?`, userID).Scan(&count); err != nil { + return 0, fmt.Errorf("count availability schedules: %w", err) + } + return count, nil +} + +// Delete removes id outright, scoped to userID like GetByID. Its ranges +// cascade via ON DELETE CASCADE. +func (r *AvailabilityScheduleRepository) Delete(ctx context.Context, id, userID int64) error { + res, err := r.db.ExecContext(ctx, `DELETE FROM availability_schedules WHERE id = ? AND user_id = ?`, id, userID) + if err != nil { + // A Booking Link's availability_schedule_id carries no ON DELETE + // clause (00015_booking_links.sql), so SQLite's own FK enforcement + // refuses this exactly as ADR-0085 requires ("deleting a Schedule + // still referenced is refused") — translated into a typed sentinel + // here rather than left as a raw SQL error, the same as every other + // constraint violation this package catches. + if strings.Contains(err.Error(), "FOREIGN KEY constraint failed") { + return ErrScheduleReferenced + } + return fmt.Errorf("delete availability schedule: %w", err) + } + return requireAffected(res) +} + +func scanAvailabilityScheduleRow(row rowScanner) (AvailabilitySchedule, error) { + var s AvailabilitySchedule + err := row.Scan(&s.ID, &s.UserID, &s.Name, &s.Tzid, &s.CreatedAt) + return s, err +} + +// listRanges returns every range belonging to any of scheduleIDs, grouped by +// schedule_id, ordered by weekday then start_minute. A single query +// regardless of how many schedules are asked for, so ListForUser stays O(1) +// round-trips rather than N+1. +func (r *AvailabilityScheduleRepository) listRanges(ctx context.Context, scheduleIDs []int64) (map[int64][]AvailabilityRange, error) { + result := make(map[int64][]AvailabilityRange, len(scheduleIDs)) + if len(scheduleIDs) == 0 { + return result, nil + } + + placeholders := make([]any, len(scheduleIDs)) + query := `SELECT id, schedule_id, weekday, start_minute, end_minute FROM availability_schedule_ranges WHERE schedule_id IN (` + for i, id := range scheduleIDs { + if i > 0 { + query += `, ` + } + query += `?` + placeholders[i] = id + } + query += `) ORDER BY weekday, start_minute` + + rows, err := r.db.QueryContext(ctx, query, placeholders...) + if err != nil { + return nil, fmt.Errorf("list availability schedule ranges: %w", err) + } + defer rows.Close() + + for rows.Next() { + var rng AvailabilityRange + if err := rows.Scan(&rng.ID, &rng.ScheduleID, &rng.Weekday, &rng.StartMinute, &rng.EndMinute); err != nil { + return nil, fmt.Errorf("scan availability schedule range: %w", err) + } + result[rng.ScheduleID] = append(result[rng.ScheduleID], rng) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate availability schedule ranges: %w", err) + } + + return result, nil +} diff --git a/server/internal/repository/booking_link.go b/server/internal/repository/booking_link.go new file mode 100644 index 0000000..e75f601 --- /dev/null +++ b/server/internal/repository/booking_link.go @@ -0,0 +1,349 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +// BookingLink is a Booking Link (#322, ADR-0084, ADR-0087): a named, +// slug-addressed offer to be booked, belonging to one User within one +// Workspace, scoped and cascaded like a Task List. ConflictCalendarIDs is +// attached by GetByID/ListForUser (booking_link_conflict_calendars is a +// separate table), never populated by Create/Update themselves. +type BookingLink struct { + ID int64 + UserID int64 + WorkspaceID int64 + Title string + Slug string + DurationMinutes int + Visibility string + AvailabilityScheduleID int64 + BookIntoCalendarID string + Location string + Description string + MinimumNoticeMinutes int + BookingHorizonDays int + // TasksInConflictSet is the Conflict set's "single row standing for + // Tasks" (CONTEXT.md) — a plain column here, unlike the Calendar half + // of the Conflict set, since Tasks has no calendar_id to put in + // booking_link_conflict_calendars. + TasksInConflictSet bool + CreatedAt time.Time + ConflictCalendarIDs []string +} + +// BookingLinkFields are a Booking Link's writable columns, gathered into +// one value the same way CalendarFields already gathers a Calendar's — so +// Create and Update take one argument each instead of separately threading +// every column. +type BookingLinkFields struct { + Title string + Slug string + DurationMinutes int + Visibility string + AvailabilityScheduleID int64 + BookIntoCalendarID string + Location string + Description string + MinimumNoticeMinutes int + BookingHorizonDays int + TasksInConflictSet bool +} + +// ErrSlugTaken is returned by Create and Update when (userID, slug) is +// already in use — a Slug is unique per User, not instance-wide (ADR-0084: +// "two people may both publish intro-call"). +var ErrSlugTaken = errors.New("slug already taken") + +type BookingLinkRepository struct { + db DBTX +} + +func NewBookingLinkRepository(db *sql.DB) *BookingLinkRepository { + return &BookingLinkRepository{db: db} +} + +// WithTx returns a copy of the repository bound to tx, for use inside +// repository.WithTx to make Create's row-plus-conflict-set writes atomic +// (ADR-0018), mirroring AvailabilityScheduleRepository's own WithTx. +func (r *BookingLinkRepository) WithTx(tx *sql.Tx) *BookingLinkRepository { + return &BookingLinkRepository{db: tx} +} + +const bookingLinkColumns = `id, user_id, workspace_id, title, slug, duration_minutes, visibility, availability_schedule_id, book_into_calendar_id, location, description, minimum_notice_minutes, booking_horizon_days, tasks_in_conflict_set, created_at` + +// Create inserts a new Booking Link row alone, owned by userID inside +// workspaceID. Its Conflict set is a separate insert (AddConflictCalendar), +// left to the caller to run in the same transaction +// (BookingLinkService.Create does exactly this). +func (r *BookingLinkRepository) Create(ctx context.Context, userID, workspaceID int64, fields BookingLinkFields) (BookingLink, error) { + res, err := r.db.ExecContext(ctx, + `INSERT INTO booking_links (user_id, workspace_id, title, slug, duration_minutes, visibility, availability_schedule_id, book_into_calendar_id, location, description, minimum_notice_minutes, booking_horizon_days, tasks_in_conflict_set) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + userID, workspaceID, fields.Title, fields.Slug, fields.DurationMinutes, fields.Visibility, fields.AvailabilityScheduleID, fields.BookIntoCalendarID, fields.Location, fields.Description, fields.MinimumNoticeMinutes, fields.BookingHorizonDays, fields.TasksInConflictSet, + ) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint failed") { + return BookingLink{}, ErrSlugTaken + } + return BookingLink{}, fmt.Errorf("insert booking link: %w", err) + } + + id, err := res.LastInsertId() + if err != nil { + return BookingLink{}, fmt.Errorf("get inserted booking link id: %w", err) + } + + return r.GetByID(ctx, id, userID, workspaceID) +} + +// Update replaces id's writable columns wholesale, scoped to userID and +// workspaceID like GetByID. Never touches the Conflict set — that's +// AddConflictCalendar/RemoveConflictCalendar's job, edited with immediate +// effect and no save step, the same split CalendarSetService draws between +// Rename and AddCalendar/RemoveCalendar. +func (r *BookingLinkRepository) Update(ctx context.Context, id, userID, workspaceID int64, fields BookingLinkFields) (BookingLink, error) { + res, err := r.db.ExecContext(ctx, + `UPDATE booking_links SET title = ?, slug = ?, duration_minutes = ?, visibility = ?, availability_schedule_id = ?, book_into_calendar_id = ?, location = ?, description = ?, minimum_notice_minutes = ?, booking_horizon_days = ?, tasks_in_conflict_set = ? + WHERE id = ? AND user_id = ? AND workspace_id = ?`, + fields.Title, fields.Slug, fields.DurationMinutes, fields.Visibility, fields.AvailabilityScheduleID, fields.BookIntoCalendarID, fields.Location, fields.Description, fields.MinimumNoticeMinutes, fields.BookingHorizonDays, fields.TasksInConflictSet, + id, userID, workspaceID, + ) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint failed") { + return BookingLink{}, ErrSlugTaken + } + return BookingLink{}, fmt.Errorf("update booking link: %w", err) + } + if err := requireAffected(res); err != nil { + return BookingLink{}, err + } + return r.GetByID(ctx, id, userID, workspaceID) +} + +// GetByID returns the Booking Link identified by id, scoped to userID and +// workspaceID together — one belonging to another User, or to a different +// Workspace, is ErrNotFound rather than any error naming the mismatch, the +// same posture as TaskListRepository.GetByID. Its Conflict set's Calendar +// half is loaded and attached. +func (r *BookingLinkRepository) GetByID(ctx context.Context, id, userID, workspaceID int64) (BookingLink, error) { + row := r.db.QueryRowContext(ctx, + `SELECT `+bookingLinkColumns+` FROM booking_links WHERE id = ? AND user_id = ? AND workspace_id = ?`, + id, userID, workspaceID, + ) + link, err := scanBookingLinkRow(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return BookingLink{}, ErrNotFound + } + return BookingLink{}, fmt.Errorf("scan booking link: %w", err) + } + + ids, err := r.listConflictCalendarIDs(ctx, []int64{id}) + if err != nil { + return BookingLink{}, err + } + link.ConflictCalendarIDs = ids[id] + + return link, nil +} + +// GetBySlug resolves the Booking Link identified by (userID, slug) — an +// exact-match lookup relying on the column's own COLLATE NOCASE for +// case-insensitivity, the same as UserRepository.GetByHandle. Used by +// BookingLinkService's slug-disambiguation loops (Duplicate) and by the +// public router (#324, #326), which resolves a Booking Link this way +// instead of by id. ConflictCalendarIDs is attached the same way GetByID +// attaches it — the public page's own slot derivation (#323) and Book's own +// re-derivation (#326, ADR-0087) both depend on it being populated here, +// not just on the authenticated GetByID/ListForUser paths. +func (r *BookingLinkRepository) GetBySlug(ctx context.Context, userID int64, slug string) (BookingLink, error) { + row := r.db.QueryRowContext(ctx, + `SELECT `+bookingLinkColumns+` FROM booking_links WHERE user_id = ? AND slug = ?`, + userID, slug, + ) + link, err := scanBookingLinkRow(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return BookingLink{}, ErrNotFound + } + return BookingLink{}, fmt.Errorf("scan booking link: %w", err) + } + + ids, err := r.listConflictCalendarIDs(ctx, []int64{link.ID}) + if err != nil { + return BookingLink{}, err + } + link.ConflictCalendarIDs = ids[link.ID] + + return link, nil +} + +// ListForUser returns every Booking Link userID owns inside workspaceID, +// ordered by when it was created, each carrying its own Conflict set. +func (r *BookingLinkRepository) ListForUser(ctx context.Context, userID, workspaceID int64) ([]BookingLink, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT `+bookingLinkColumns+` FROM booking_links WHERE user_id = ? AND workspace_id = ? ORDER BY created_at, id`, + userID, workspaceID, + ) + if err != nil { + return nil, fmt.Errorf("list booking links: %w", err) + } + links, err := collectRows(rows, scanBookingLinkRow) + if err != nil { + return nil, err + } + + ids := make([]int64, len(links)) + for i, l := range links { + ids[i] = l.ID + } + idsByLink, err := r.listConflictCalendarIDs(ctx, ids) + if err != nil { + return nil, err + } + for i := range links { + links[i].ConflictCalendarIDs = idsByLink[links[i].ID] + } + + return links, nil +} + +// ListPublicForUser returns every Public Booking Link userID owns, across +// every Workspace — the public index page's own union (#325, ADR-0084), +// scoped the same User-wide way CountForUser already is rather than by one +// Workspace, since a visitor has no Session and therefore no single +// Workspace to scope by. Carries no Conflict set: the index shows only +// Title, Slug and Duration. +func (r *BookingLinkRepository) ListPublicForUser(ctx context.Context, userID int64) ([]BookingLink, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT `+bookingLinkColumns+` FROM booking_links WHERE user_id = ? AND visibility = 'public' ORDER BY created_at, id`, + userID, + ) + if err != nil { + return nil, fmt.Errorf("list public booking links: %w", err) + } + return collectRows(rows, scanBookingLinkRow) +} + +// CountForUser returns how many Booking Links userID owns across every +// Workspace — deliberately not scoped to one Workspace, since "a User's +// first Booking Link" (the Handle auto-claim trigger, ADR-0084) is a +// User-wide count, on the same terms as the Handle it claims. +func (r *BookingLinkRepository) CountForUser(ctx context.Context, userID int64) (int, error) { + var count int + if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM booking_links WHERE user_id = ?`, userID).Scan(&count); err != nil { + return 0, fmt.Errorf("count booking links: %w", err) + } + return count, nil +} + +// Delete removes id outright, scoped to userID and workspaceID like +// GetByID. Its Conflict set rows cascade via ON DELETE CASCADE. +func (r *BookingLinkRepository) Delete(ctx context.Context, id, userID, workspaceID int64) error { + res, err := r.db.ExecContext(ctx, `DELETE FROM booking_links WHERE id = ? AND user_id = ? AND workspace_id = ?`, id, userID, workspaceID) + if err != nil { + return fmt.Errorf("delete booking link: %w", err) + } + return requireAffected(res) +} + +// DeleteAllForUser removes every Booking Link userID owns, across every +// Workspace. AccountService.Delete calls this, tx-bound, before it deletes +// or transfers any of userID's own Calendars — a Booking Link has no +// transfer semantics of its own, and book_into_calendar_id's own FK +// (00015_booking_links.sql, no ON DELETE clause) would otherwise refuse a +// DispositionDelete on a Calendar this User's own Booking Link still +// points at, since that Calendar's owning User hasn't been deleted yet at +// that point in the transaction — user_id's ON DELETE CASCADE only fires +// once the users row itself goes, later in the same transaction. +func (r *BookingLinkRepository) DeleteAllForUser(ctx context.Context, userID int64) error { + if _, err := r.db.ExecContext(ctx, `DELETE FROM booking_links WHERE user_id = ?`, userID); err != nil { + return fmt.Errorf("delete all booking links for user: %w", err) + } + return nil +} + +// AddConflictCalendar puts calendarID into id's Conflict set. Idempotent: a +// Calendar already in the set is not an error, matching +// CalendarSetRepository.AddCalendar's own INSERT OR IGNORE. +func (r *BookingLinkRepository) AddConflictCalendar(ctx context.Context, linkID int64, calendarID string) error { + if _, err := r.db.ExecContext(ctx, + `INSERT OR IGNORE INTO booking_link_conflict_calendars (link_id, calendar_id) VALUES (?, ?)`, + linkID, calendarID, + ); err != nil { + return fmt.Errorf("add conflict calendar: %w", err) + } + return nil +} + +// RemoveConflictCalendar takes calendarID out of id's Conflict set. +// Refusing to remove the Book-into Calendar is BookingLinkService's job, +// checked before this is ever called — this method itself doesn't inspect +// which calendar_id is pinned. +func (r *BookingLinkRepository) RemoveConflictCalendar(ctx context.Context, linkID int64, calendarID string) error { + if _, err := r.db.ExecContext(ctx, + `DELETE FROM booking_link_conflict_calendars WHERE link_id = ? AND calendar_id = ?`, + linkID, calendarID, + ); err != nil { + return fmt.Errorf("remove conflict calendar: %w", err) + } + return nil +} + +func scanBookingLinkRow(row rowScanner) (BookingLink, error) { + var l BookingLink + err := row.Scan( + &l.ID, &l.UserID, &l.WorkspaceID, &l.Title, &l.Slug, &l.DurationMinutes, &l.Visibility, + &l.AvailabilityScheduleID, &l.BookIntoCalendarID, &l.Location, &l.Description, + &l.MinimumNoticeMinutes, &l.BookingHorizonDays, &l.TasksInConflictSet, &l.CreatedAt, + ) + return l, err +} + +// listConflictCalendarIDs returns every Calendar id in any of linkIDs' +// Conflict sets, grouped by link_id. A single query regardless of how many +// links are asked for, so ListForUser stays O(1) round-trips rather than +// N+1, mirroring AvailabilityScheduleRepository.listRanges. +func (r *BookingLinkRepository) listConflictCalendarIDs(ctx context.Context, linkIDs []int64) (map[int64][]string, error) { + result := make(map[int64][]string, len(linkIDs)) + if len(linkIDs) == 0 { + return result, nil + } + + placeholders := make([]any, len(linkIDs)) + query := `SELECT link_id, calendar_id FROM booking_link_conflict_calendars WHERE link_id IN (` + for i, id := range linkIDs { + if i > 0 { + query += `, ` + } + query += `?` + placeholders[i] = id + } + query += `) ORDER BY calendar_id` + + rows, err := r.db.QueryContext(ctx, query, placeholders...) + if err != nil { + return nil, fmt.Errorf("list booking link conflict calendars: %w", err) + } + defer rows.Close() + + for rows.Next() { + var linkID int64 + var calendarID string + if err := rows.Scan(&linkID, &calendarID); err != nil { + return nil, fmt.Errorf("scan booking link conflict calendar: %w", err) + } + result[linkID] = append(result[linkID], calendarID) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate booking link conflict calendars: %w", err) + } + + return result, nil +} diff --git a/server/internal/repository/calendar.go b/server/internal/repository/calendar.go index 60df196..c759557 100644 --- a/server/internal/repository/calendar.go +++ b/server/internal/repository/calendar.go @@ -5,9 +5,17 @@ import ( "database/sql" "errors" "fmt" + "strings" "time" ) +// ErrCalendarReferenced is returned by Delete when id is still the +// BookIntoCalendarID of some Booking Link (#322, ADR-0087) — deleting a +// Calendar a Booking Link writes into is refused, the same "still +// referenced" posture ErrScheduleReferenced already takes toward an +// Availability Schedule. +var ErrCalendarReferenced = errors.New("calendar still referenced by a booking link") + type Calendar struct { ID string UserID int64 @@ -266,6 +274,14 @@ func (r *CalendarRepository) UpdateName(ctx context.Context, userID int64, id, n func (r *CalendarRepository) Delete(ctx context.Context, userID int64, id string) error { res, err := r.db.ExecContext(ctx, `DELETE FROM calendars WHERE user_id = ? AND id = ?`, userID, id) if err != nil { + // A Booking Link's book_into_calendar_id carries no ON DELETE clause + // (00015_booking_links.sql), so SQLite's own FK enforcement refuses + // this exactly as ADR-0087 requires — translated into a typed + // sentinel here rather than left as a raw SQL error, mirroring + // AvailabilityScheduleRepository.Delete's ErrScheduleReferenced. + if strings.Contains(err.Error(), "FOREIGN KEY constraint failed") { + return ErrCalendarReferenced + } return fmt.Errorf("delete calendar: %w", err) } diff --git a/server/internal/repository/calendar_exposure.go b/server/internal/repository/calendar_exposure.go new file mode 100644 index 0000000..01c375a --- /dev/null +++ b/server/internal/repository/calendar_exposure.go @@ -0,0 +1,68 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +// CalendarExposureRepository stores each User's own choice about whether a +// Calendar appears in their own CalDAV home-set (ADR-0080, CONTEXT.md's +// Exposure entry). Keyed directly on (calendar_id, user_id), the same shape +// calendar_user_colors already uses (ADR-0038) — there is no wholesale- +// replace-on-update to survive here either. +type CalendarExposureRepository struct { + db DBTX +} + +func NewCalendarExposureRepository(db *sql.DB) *CalendarExposureRepository { + return &CalendarExposureRepository{db: db} +} + +// WithTx returns a copy of the repository bound to tx, for use inside +// repository.WithTx to make a multi-table write atomic (ADR-0018). +func (r *CalendarExposureRepository) WithTx(tx *sql.Tx) *CalendarExposureRepository { + return &CalendarExposureRepository{db: tx} +} + +// Upsert sets userID's own Exposure choice on calendarID, replacing whatever +// was there — a User changing their mind again is a replace, not a merge. +func (r *CalendarExposureRepository) Upsert(ctx context.Context, userID int64, calendarID string, exposed bool) error { + if _, err := r.db.ExecContext(ctx, + `INSERT INTO calendar_exposures (calendar_id, user_id, exposed) VALUES (?, ?, ?) + ON CONFLICT(calendar_id, user_id) DO UPDATE SET exposed = excluded.exposed`, + calendarID, userID, exposed, + ); err != nil { + return fmt.Errorf("upsert calendar exposure: %w", err) + } + return nil +} + +// Get returns userID's own Exposure override on calendarID, or ErrNotFound +// if they've never set one — the fallback case, where the default applies. +func (r *CalendarExposureRepository) Get(ctx context.Context, userID int64, calendarID string) (bool, error) { + var exposed bool + err := r.db.QueryRowContext(ctx, + `SELECT exposed FROM calendar_exposures WHERE calendar_id = ? AND user_id = ?`, + calendarID, userID, + ).Scan(&exposed) + if errors.Is(err, sql.ErrNoRows) { + return false, ErrNotFound + } + if err != nil { + return false, fmt.Errorf("scan calendar exposure: %w", err) + } + return exposed, nil +} + +// Delete clears userID's own Exposure override on calendarID — RevokeShare's +// and LeaveShare's cleanup, mirroring calendar_user_colors' own Delete +// (ADR-0038): an Exposure choice with no Access behind it would otherwise +// linger, invisible, until userID was ever given Access again. +func (r *CalendarExposureRepository) Delete(ctx context.Context, userID int64, calendarID string) error { + if _, err := r.db.ExecContext(ctx, `DELETE FROM calendar_exposures WHERE calendar_id = ? AND user_id = ?`, calendarID, userID); err != nil { + return fmt.Errorf("delete calendar exposure: %w", err) + } + return nil +} diff --git a/server/internal/repository/calendar_set.go b/server/internal/repository/calendar_set.go new file mode 100644 index 0000000..4a5f64f --- /dev/null +++ b/server/internal/repository/calendar_set.go @@ -0,0 +1,169 @@ +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) +} + +// 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/repository/event.go b/server/internal/repository/event.go index d9c84b5..bcf0e57 100644 --- a/server/internal/repository/event.go +++ b/server/internal/repository/event.go @@ -34,6 +34,11 @@ type Event struct { // range. Start/end still hold the half-open date range (start = the date, // end = the exclusive next day). See ADR-0017. AllDay bool + // Busy is iCalendar's own TRANSP (OPAQUE/TRANSPARENT): whether this Event + // consumes the time it occupies. A timed Event defaults to Busy, matching + // RFC 5545; an all-day Event defaults to Free — see ADR-0086. Only a + // Busy Event will later close a Booking Link's slot. + Busy bool // Tzid is the IANA zone the Event's wall-clock is anchored to (the // Anchor zone): a named zone is a zoned Event, "Etc/UTC" is an absolute // instant, and nil is a Floating Event that renders/expands in the @@ -184,6 +189,8 @@ type EventFields struct { Title string Start, End time.Time AllDay bool + // Busy mirrors Event.Busy — iCalendar's own TRANSP (ADR-0086). + Busy bool Rrule string ParentID *string RecurrenceID *time.Time @@ -197,19 +204,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 @@ -217,10 +222,34 @@ type EventFields struct { ProviderColor *string } +// transpOpaque and transpTransparent are the two values iCalendar's TRANSP +// property carries (ADR-0086) — the wire vocabulary busyToTransp/transpToBusy +// translate Event.Busy through, so the column stores the domain's own term +// rather than a bare 0/1 that would mean nothing on a raw SQL browse. +const ( + transpOpaque = "OPAQUE" + transpTransparent = "TRANSPARENT" +) + +// busyToTransp renders busy as TRANSP's own text vocabulary (ADR-0086). +func busyToTransp(busy bool) string { + if busy { + return transpOpaque + } + return transpTransparent +} + +// transpToBusy is busyToTransp's inverse. Anything other than the literal +// "TRANSPARENT" reads as Busy — RFC 5545's own default for a VEVENT whose +// TRANSP is absent or unrecognized. +func transpToBusy(transp string) bool { + return transp != transpTransparent +} + func (r *EventRepository) Create(ctx context.Context, id string, createdBy *int64, f EventFields, changeSeq int64) (Event, error) { if _, err := r.db.ExecContext(ctx, - `INSERT INTO events (id, calendar_id, title, "start", "end", all_day, rrule, parent_id, recurrence_id, tzid, description, location, url, color, external_uid, created_by, change_seq, provider_etag, rsvp_status, conference_url, guest_count, provider_color) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - id, f.CalendarID, f.Title, f.Start.UTC(), f.End.UTC(), f.AllDay, f.Rrule, f.ParentID, utcPtr(f.RecurrenceID), f.Tzid, f.Description, f.Location, f.URL, f.Color, f.ExternalUID, createdBy, changeSeq, f.ProviderEtag, f.RSVPStatus, f.ConferenceURL, f.GuestCount, f.ProviderColor, + `INSERT INTO events (id, calendar_id, title, "start", "end", all_day, transp, rrule, parent_id, recurrence_id, tzid, description, location, url, color, external_uid, created_by, change_seq, provider_etag, rsvp_status, conference_url, guest_count, provider_color) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, f.CalendarID, f.Title, f.Start.UTC(), f.End.UTC(), f.AllDay, busyToTransp(f.Busy), f.Rrule, f.ParentID, utcPtr(f.RecurrenceID), f.Tzid, f.Description, f.Location, f.URL, f.Color, f.ExternalUID, createdBy, changeSeq, f.ProviderEtag, f.RSVPStatus, f.ConferenceURL, f.GuestCount, f.ProviderColor, ); err != nil { return Event{}, fmt.Errorf("insert event: %w", err) } @@ -228,7 +257,7 @@ func (r *EventRepository) Create(ctx context.Context, id string, createdBy *int6 return r.GetByID(ctx, id) } -const eventColumns = `id, calendar_id, title, "start", "end", all_day, rrule, parent_id, recurrence_id, tzid, description, location, url, color, external_uid, created_by, created_at, change_seq, sequence, provider_etag, rsvp_status, conference_url, guest_count, provider_color, write_back_error` +const eventColumns = `id, calendar_id, title, "start", "end", all_day, transp, rrule, parent_id, recurrence_id, tzid, description, location, url, color, external_uid, created_by, created_at, change_seq, sequence, provider_etag, rsvp_status, conference_url, guest_count, provider_color, write_back_error` func (r *EventRepository) GetByID(ctx context.Context, id string) (Event, error) { return scanEvent(r.db.QueryRowContext(ctx, @@ -377,14 +406,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 = ?, transp = ?, rrule = ?, tzid = ?, description = ?, location = ?, url = ?, color = ?, change_seq = ?, sequence = ? WHERE id = ?`, + f.CalendarID, f.Title, f.Start.UTC(), f.End.UTC(), f.AllDay, busyToTransp(f.Busy), 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 +467,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) @@ -522,8 +560,12 @@ func (r *EventRepository) BumpChangeSeqForCalendar(ctx context.Context, calendar // Linked Calendar becomes an ordinary owned Calendar with nothing left // pointing at an account that is no longer connected. Affects zero rows on // an empty Calendar, so it deliberately does not go through requireAffected. -// change_seq never bumps — a Linked Calendar reaches no CalDAV client -// (ADR-0074), so there is no sync stream for this to perturb. +// change_seq never bumps — every column this clears (external_uid, +// provider_etag, rsvp_status, conference_url, guest_count, provider_color, +// write_back_error) is Provider-identity bookkeeping the CalDAV object +// mapping never serializes into an Event's .ics bytes (ADR-0025), so there +// is no sync stream for clearing them to perturb even now that Exposure +// (ADR-0080) can put a Linked Calendar in front of a CalDAV client. func (r *EventRepository) ClearProviderIdentityByCalendar(ctx context.Context, calendarID string) error { if _, err := r.db.ExecContext(ctx, `UPDATE events SET external_uid = NULL, provider_etag = NULL, rsvp_status = NULL, @@ -662,9 +704,11 @@ func scanEventRow(row rowScanner, e *Event) error { var conferenceURL sql.NullString var providerColor sql.NullString var writeBackError sql.NullString - if err := row.Scan(&e.ID, &e.CalendarID, &e.Title, &e.Start, &e.End, &e.AllDay, &e.Rrule, &parentID, &recurrenceID, &tzid, &description, &location, &url, &color, &externalUID, &createdBy, &e.CreatedAt, &e.ChangeSeq, &e.Sequence, &providerEtag, &rsvpStatus, &conferenceURL, &e.GuestCount, &providerColor, &writeBackError); err != nil { + var transp string + if err := row.Scan(&e.ID, &e.CalendarID, &e.Title, &e.Start, &e.End, &e.AllDay, &transp, &e.Rrule, &parentID, &recurrenceID, &tzid, &description, &location, &url, &color, &externalUID, &createdBy, &e.CreatedAt, &e.ChangeSeq, &e.Sequence, &providerEtag, &rsvpStatus, &conferenceURL, &e.GuestCount, &providerColor, &writeBackError); err != nil { return err } + e.Busy = transpToBusy(transp) if externalUID.Valid { e.ExternalUID = &externalUID.String } diff --git a/server/internal/repository/event_test.go b/server/internal/repository/event_test.go index 0504658..15acd95 100644 --- a/server/internal/repository/event_test.go +++ b/server/internal/repository/event_test.go @@ -176,6 +176,40 @@ func TestEventRepository_CreateAndUpdate_PersistsAllDay(t *testing.T) { } } +// TestEventRepository_CreateAndUpdate_PersistsBusy round-trips both Busy +// (Free explicitly cleared to Busy on update) and Free (ADR-0086). +func TestEventRepository_CreateAndUpdate_PersistsBusy(t *testing.T) { + repo, userID, calendarID, _ := newTestEventRepository(t) + ctx := context.Background() + + start := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 8, 5, 0, 0, 0, 0, time.UTC) + + created, err := repo.Create(ctx, "evt-1", &userID, EventFields{CalendarID: calendarID, Title: "Holiday", Start: start, End: end, AllDay: true, Busy: false}, 0) + if err != nil { + t.Fatalf("create event: %v", err) + } + if created.Busy { + t.Fatalf("expected created event to be Free, got %+v", created) + } + + fetched, err := repo.GetByID(ctx, "evt-1") + if err != nil { + t.Fatalf("get by id: %v", err) + } + if fetched.Busy { + t.Fatalf("expected fetched event to stay Free, got %+v", fetched) + } + + updated, err := repo.Update(ctx, "evt-1", EventFields{CalendarID: calendarID, Title: "Holiday", Start: start, End: end, AllDay: true, Busy: true}, 0, 0) + if err != nil { + t.Fatalf("update: %v", err) + } + if !updated.Busy { + t.Fatalf("expected updated event to be Busy, got %+v", updated) + } +} + // A nil tzid (Floating Event, ADR-0019) must round-trip as nil, and a named // zone must round-trip verbatim through create, get, and update. func TestEventRepository_CreateAndUpdate_RoundTripsTzid(t *testing.T) { @@ -814,9 +848,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 +859,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 +877,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/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/repository/outbox.go b/server/internal/repository/outbox.go index 2e31689..7ef02b7 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" ) @@ -48,6 +54,14 @@ const ( OutboxMethodDelete = "DELETE" OutboxMethodInstance = "INSTANCE" OutboxMethodCancelInstance = "CANCEL_INSTANCE" + // OutboxMethodBookingNotice is a plain, pre-rendered mail notice tied to + // Booking Link cancellation (#327, ADR-0087): the visitor's booking + // confirmation (carrying the signed cancel link) and the host's + // cancellation notice both take this shape. Neither is an Invitation or + // a Cancellation in ADR-0059's sense — no METHOD:REQUEST/CANCEL card, no + // Attendee lifecycle — so InvitationSender relays this one verbatim + // rather than rendering an iCalendar part for it. + OutboxMethodBookingNotice = "BOOKING_NOTICE" ) // OutboxKindMail is every row this table carried before #290: a queued @@ -130,6 +144,18 @@ type OutboxWriteBackInstanceSnapshot struct { OverrideEventID string `json:"overrideEventId,omitempty"` } +// OutboxBookingNoticeSnapshot is a BOOKING_NOTICE row's payload (#327, +// ADR-0087): a subject and body already rendered by the caller at enqueue +// time (PublicBookingService, for the visitor's confirmation and the host's +// cancellation notice), so InvitationSender needs no Event/Attendee lookup +// and no per-purpose formatting of its own — unlike a REQUEST, which always +// rebuilds from live state, this is meant to say exactly what it said when +// queued. +type OutboxBookingNoticeSnapshot struct { + Subject string `json:"subject"` + Body string `json:"body"` +} + // OutboxMessage is a queued Invitation or Cancellation email (ADR-0059, // ADR-0060, #201): written in the same transaction as the Attendee row it // accompanies (or, for a CANCEL, the row/Attendee-row it withdraws), so a @@ -163,12 +189,16 @@ type OutboxMessage struct { Snapshot *OutboxCancelSnapshot WriteBackDelete *OutboxWriteBackDeleteSnapshot WriteBackInstance *OutboxWriteBackInstanceSnapshot - Status string - Attempts int - NextAttemptAt time.Time - LastError string - CreatedAt time.Time - SentAt *time.Time + // BookingNotice is non-nil only for a mail BOOKING_NOTICE (#327, + // ADR-0087), sharing the same underlying `snapshot` column as the other + // three. + BookingNotice *OutboxBookingNoticeSnapshot + Status string + Attempts int + NextAttemptAt time.Time + LastError string + CreatedAt time.Time + SentAt *time.Time } // OutboxRepository stores queued Invitation emails, drained by the @@ -331,6 +361,33 @@ func (r *OutboxRepository) enqueueCancel(ctx context.Context, eventID string, re return r.Get(ctx, id) } +// EnqueueBookingNotice writes a pending mail BOOKING_NOTICE OutboxMessage to +// recipientEmail (#327, ADR-0087) — the visitor's booking confirmation +// (carrying the signed cancel link) or the host's cancellation notice, +// depending on who recipientEmail is. subject/body are rendered by the +// caller and relayed verbatim by InvitationSender, unlike a REQUEST/CANCEL. +func (r *OutboxRepository) EnqueueBookingNotice(ctx context.Context, eventID, recipientEmail, subject, body string) (OutboxMessage, error) { + encoded, err := json.Marshal(OutboxBookingNoticeSnapshot{Subject: subject, Body: body}) + if err != nil { + return OutboxMessage{}, fmt.Errorf("marshal booking notice snapshot: %w", err) + } + + res, err := r.db.ExecContext(ctx, + `INSERT INTO outbox (event_id, recipient_email, method, snapshot) VALUES (?, ?, ?, ?)`, + eventID, recipientEmail, OutboxMethodBookingNotice, string(encoded), + ) + if err != nil { + return OutboxMessage{}, fmt.Errorf("insert outbox message: %w", err) + } + + id, err := res.LastInsertId() + if err != nil { + return OutboxMessage{}, fmt.Errorf("get last insert id: %w", err) + } + + return r.Get(ctx, id) +} + // EnqueueWriteBack writes a pending PATCH OutboxMessage of kind // OutboxKindWriteBack for eventID (#290, ADR-0075, ADR-0076): the local write // and this call happen in the same transaction (EventService.Update's own @@ -596,6 +653,12 @@ func scanOutboxMessage(row rowScanner) (OutboxMessage, error) { return OutboxMessage{}, fmt.Errorf("unmarshal write-back instance snapshot: %w", err) } m.WriteBackInstance = &s + case m.Kind == OutboxKindMail && m.Method == OutboxMethodBookingNotice: + var s OutboxBookingNoticeSnapshot + if err := json.Unmarshal([]byte(snapshot.String), &s); err != nil { + return OutboxMessage{}, fmt.Errorf("unmarshal booking notice snapshot: %w", err) + } + m.BookingNotice = &s default: var s OutboxCancelSnapshot if err := json.Unmarshal([]byte(snapshot.String), &s); err != nil { @@ -671,6 +734,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/repository/rate_limit.go b/server/internal/repository/rate_limit.go index bfbdbde..312371a 100644 --- a/server/internal/repository/rate_limit.go +++ b/server/internal/repository/rate_limit.go @@ -15,6 +15,10 @@ import ( const ( RateLimitScopeAuth = "auth" RateLimitScopeRegister = "register" + // RateLimitScopePublicBooking is the public Booking Link page's own + // scope (#324, ADR-0087) — the first unauthenticated surface that reads + // calendar data, throttled per IP on the same terms as Register. + RateLimitScopePublicBooking = "public_booking" ) // RateLimitKeyEmail and RateLimitKeyIP are the two key_type values a diff --git a/server/internal/repository/task.go b/server/internal/repository/task.go new file mode 100644 index 0000000..c56d49b --- /dev/null +++ b/server/internal/repository/task.go @@ -0,0 +1,312 @@ +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) +} + +// 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) +} + +// SetTimeBlock sets id's Time block (start + duration_minutes), scoped to +// userID and workspaceID like GetByID — the Deadline (`due`) is untouched, +// the two axes being independent (ADR-0083). +func (r *TaskRepository) SetTimeBlock(ctx context.Context, id, userID, workspaceID int64, start time.Time, durationMinutes int) (Task, error) { + res, err := r.db.ExecContext(ctx, + `UPDATE tasks SET start = ?, duration_minutes = ? WHERE id = ? AND user_id = ? AND workspace_id = ?`, + start, durationMinutes, id, userID, workspaceID, + ) + if err != nil { + return Task{}, fmt.Errorf("set task time block: %w", err) + } + if err := requireAffected(res); err != nil { + return Task{}, err + } + return r.GetByID(ctx, id, userID, workspaceID) +} + +// ClearTimeBlock clears id's Time block (start and duration_minutes both to +// NULL), scoped to userID and workspaceID like GetByID. The Deadline is +// untouched. +func (r *TaskRepository) ClearTimeBlock(ctx context.Context, id, userID, workspaceID int64) (Task, error) { + res, err := r.db.ExecContext(ctx, + `UPDATE tasks SET start = NULL, duration_minutes = NULL WHERE id = ? AND user_id = ? AND workspace_id = ?`, + id, userID, workspaceID, + ) + if err != nil { + return Task{}, fmt.Errorf("clear task time block: %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, + `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 new file mode 100644 index 0000000..89552e8 --- /dev/null +++ b/server/internal/repository/task_list.go @@ -0,0 +1,191 @@ +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 +} + +// 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, + `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/repository/user.go b/server/internal/repository/user.go index beeff4e..04c6a5d 100644 --- a/server/internal/repository/user.go +++ b/server/internal/repository/user.go @@ -57,13 +57,18 @@ type User struct { // Nil means no shading (ADR-0039). WorkingHoursStart *int WorkingHoursEnd *int + + // Handle is a User's public name (#321, ADR-0084) — nullable, unique + // instance-wide, COLLATE NOCASE like Email. Absent until claimed in + // Settings → Account; a User who never publishes anything has none. + Handle *string } // userColumns is the column list every SELECT against users shares, in the // exact order scanUserFields expects — kept as one constant so the two never // drift apart across GetByID, GetByIDs, GetByEmail, ListEnabledExcluding, // and First. -const userColumns = `id, name, password_hash, must_change_password, email, synced_device_reminders_enabled, is_disabled, created_at, week_start, default_view, time_format, working_hours_start, working_hours_end, token_version` +const userColumns = `id, name, password_hash, must_change_password, email, synced_device_reminders_enabled, is_disabled, created_at, week_start, default_view, time_format, working_hours_start, working_hours_end, token_version, handle` type UserRepository struct { db DBTX @@ -85,6 +90,11 @@ func (r *UserRepository) WithTx(tx *sql.Tx) *UserRepository { // already in use — email is unique instance-wide (ADR-0047). var ErrEmailTaken = errors.New("email already taken") +// ErrHandleTaken is returned by UpdateHandle when the Handle is already +// claimed — a Handle is unique instance-wide, compared case-insensitively +// like Email (#321, ADR-0084). +var ErrHandleTaken = errors.New("handle already taken") + func (r *UserRepository) Create(ctx context.Context, name, email, passwordHash string, mustChangePassword bool) (User, error) { res, err := r.db.ExecContext(ctx, `INSERT INTO users (name, email, password_hash, must_change_password) VALUES (?, ?, ?, ?)`, @@ -152,6 +162,17 @@ func (r *UserRepository) GetByEmail(ctx context.Context, email string) (User, er )) } +// GetByHandle resolves the User identified by handle (#321, ADR-0084) — an +// exact-match lookup relying on the column's own COLLATE NOCASE for +// case-insensitivity, the same as GetByEmail. Used by +// AuthService.SuggestHandle to check a candidate's availability, and later +// by the public router resolving `/:handle`. +func (r *UserRepository) GetByHandle(ctx context.Context, handle string) (User, error) { + return r.scanUser(r.db.QueryRowContext(ctx, + `SELECT `+userColumns+` FROM users WHERE handle = ?`, handle, + )) +} + // ListEnabledExcluding returns every enabled User except excludeID, ordered // by name — the User directory (#113): any authenticated caller may see // who else has an account, so they can pick a Share recipient, but a @@ -192,6 +213,21 @@ func (r *UserRepository) UpdateName(ctx context.Context, userID int64, name stri return r.GetByID(ctx, userID) } +// UpdateHandle claims or changes userID's public name (#321, ADR-0084). A +// unique-constraint violation is surfaced as ErrHandleTaken, mirroring +// UpdateEmail against the same idx_users_handle index. Unlike Email, this +// never clears back to no Handle — nothing in this codebase asks for that +// yet, and ADR-0084 only ever describes claiming or renaming one. +func (r *UserRepository) UpdateHandle(ctx context.Context, userID int64, handle string) (User, error) { + if _, err := r.db.ExecContext(ctx, `UPDATE users SET handle = ? WHERE id = ?`, handle, userID); err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint failed") { + return User{}, ErrHandleTaken + } + return User{}, fmt.Errorf("update handle: %w", err) + } + return r.GetByID(ctx, userID) +} + // UpdateSyncedDeviceReminders sets userID's "let my synced devices show // reminder pop-ups" preference (ADR-0027). func (r *UserRepository) UpdateSyncedDeviceReminders(ctx context.Context, userID int64, enabled bool) (User, error) { @@ -351,11 +387,12 @@ func (r *UserRepository) scanUserRow(row rowScanner) (User, error) { func scanUserFields(scan func(dest ...any) error) (User, error) { var u User var workingHoursStart, workingHoursEnd sql.NullInt64 + var handle sql.NullString // modernc.org/sqlite converts the TIMESTAMP column straight into time.Time // based on the declared column type — no manual parsing needed here. err := scan( &u.ID, &u.Name, &u.PasswordHash, &u.MustChangePassword, &u.Email, &u.SyncedDeviceRemindersEnabled, &u.IsDisabled, &u.CreatedAt, - &u.WeekStart, &u.DefaultView, &u.TimeFormat, &workingHoursStart, &workingHoursEnd, &u.TokenVersion, + &u.WeekStart, &u.DefaultView, &u.TimeFormat, &workingHoursStart, &workingHoursEnd, &u.TokenVersion, &handle, ) if err != nil { return User{}, err @@ -368,5 +405,8 @@ func scanUserFields(scan func(dest ...any) error) (User, error) { v := int(workingHoursEnd.Int64) u.WorkingHoursEnd = &v } + if handle.Valid { + u.Handle = &handle.String + } return u, nil } diff --git a/server/internal/router/router.go b/server/internal/router/router.go index 9f12b98..a695a8c 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, taskListHandler *handlers.TaskListHandler, taskHandler *handlers.TaskHandler, connectionHandler *handlers.ConnectionHandler, availabilityScheduleHandler *handlers.AvailabilityScheduleHandler, bookingLinkHandler *handlers.BookingLinkHandler, publicBookingHandler *handlers.PublicBookingHandler, 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) @@ -61,6 +61,12 @@ func New(logger *slog.Logger, authHandler *handlers.AuthHandler, calendarHandler r.Get("/me", authHandler.Me) r.Put("/email", authHandler.UpdateEmail) r.Put("/name", authHandler.UpdateName) + // Handle (#321, ADR-0084): a User's public name, claimed and + // changed in Settings → Account. Suggest is read-only and + // side-effect-free, so it needs no more gating than any other + // authenticated GET. + r.Get("/handle-suggestion", authHandler.SuggestHandle) + r.Put("/handle", authHandler.UpdateHandle) r.Put("/synced-device-reminders", authHandler.UpdateSyncedDeviceReminders) r.Patch("/preferences", authHandler.UpdatePreferences) }) @@ -98,6 +104,12 @@ func New(logger *slog.Logger, authHandler *handlers.AuthHandler, calendarHandler r.Get("/{id}/default-reminders", calendarHandler.GetDefaultReminders) r.Put("/{id}/default-reminders", calendarHandler.SetDefaultReminders) + // Exposure (ADR-0080): the caller's own choice of whether this + // Calendar appears in their own CalDAV home-set, beside their + // colour override and Default reminders — open to any User with + // Access, not Owner-only, same posture as both. + r.Put("/{id}/exposure", calendarHandler.SetExposure) + // Sharing (ADR-0034): grant/revoke/list are Owner-only, // enforced by CalendarService rather than here; leave needs no // such check. @@ -235,6 +247,127 @@ 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) + + // 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) + }) + + // 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) + }) + + // 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.Patch("/{id}/notes", taskHandler.UpdateNotes) + r.Put("/{id}/deadline", taskHandler.SetDeadline) + r.Delete("/{id}/deadline", taskHandler.ClearDeadline) + r.Put("/{id}/time-block", taskHandler.SetTimeBlock) + r.Delete("/{id}/time-block", taskHandler.ClearTimeBlock) + 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) + }) + + // Availability Schedules (#320, ADR-0085): a named weekly pattern of + // time ranges belonging to one User, private outright and carrying no + // Workspace of its own — gated by RequireAuth alone, unlike Calendar + // Sets and Task Lists, since there is no Workspace to require. + // AvailabilityScheduleService itself resolves every Schedule by (id, + // caller) and refuses with ErrNotFound rather than any authority + // check, so no extra middleware gate is needed. + r.Route("/availability-schedules", func(r chi.Router) { + r.Use(httpauth.RequireAuth(authenticator)) + r.Use(httpauth.RequireActiveUser(activeUserChecker)) + r.Use(httpauth.RequireEnabledUser(enabledChecker)) + + r.Get("/", availabilityScheduleHandler.List) + r.Post("/", availabilityScheduleHandler.Create) + r.Patch("/{id}", availabilityScheduleHandler.Update) + r.Delete("/{id}", availabilityScheduleHandler.Delete) + }) + + // Booking Links (#322, ADR-0084, ADR-0087): a named, slug-addressed + // offer to be booked, scoped to (caller, active workspace) like Task + // Lists — BookingLinkService itself resolves every Booking Link 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("/booking-links", 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("/", bookingLinkHandler.List) + r.Post("/", bookingLinkHandler.Create) + r.Patch("/{id}", bookingLinkHandler.Update) + r.Delete("/{id}", bookingLinkHandler.Delete) + r.Post("/{id}/duplicate", bookingLinkHandler.Duplicate) + + // Slots (#323, ADR-0087): derived server-side even though this + // route itself is still authenticated — nothing is public yet + // (#322), the public page (#324) is what will eventually reach + // this same derivation with no Session at all. + r.Get("/{id}/slots", bookingLinkHandler.Slots) + + // Conflict set (ADR-0087): add/remove a Calendar, one at a time, + // with immediate effect — the same shape Calendar Sets' own + // membership routes take. + r.Put("/{id}/conflict-set/calendars/{calendarId}", bookingLinkHandler.AddConflictCalendar) + r.Delete("/{id}/conflict-set/calendars/{calendarId}", bookingLinkHandler.RemoveConflictCalendar) + }) + // 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 @@ -276,6 +409,39 @@ func New(logger *slog.Logger, authHandler *handlers.AuthHandler, calendarHandler r.With(httpauth.RequireEnabledUser(enabledChecker)).Get("/delete-impact", accountHandler.DeleteImpact) r.With(httpauth.RequireEnabledUser(enabledChecker)).Delete("/", accountHandler.Delete) }) + + // The public Booking Link page (#324, ADR-0084, ADR-0087): the + // stranger-facing surface with no Session at all — no RequireAuth, no + // RequireWorkspace, nothing but rate limiting, enforced inside + // PublicBookingHandler itself rather than as middleware here, since + // its ceiling is IP-only and shares no shape with httpauth's + // Authenticator-based gates. handle/slug resolution (reserved words, + // Visibility, SMTP, Access) all happen inside PublicBookingService; + // this router only needs to know the path shape. + r.Route("/public", func(r chi.Router) { + r.Get("/{handle}/{slug}", publicBookingHandler.Get) + r.Get("/{handle}/{slug}/slots", publicBookingHandler.Slots) + // Book (#326, ADR-0087): the first public *write* — creates an + // Event, invites the visitor as an Attendee, and (SMTP permitting) + // queues their Invitation. Gated the same as the reads above: + // rate limiting only, enforced inside PublicBookingHandler itself. + r.Post("/{handle}/{slug}/book", publicBookingHandler.Book) + + // Cancel (#327, ADR-0087): the signed cancel link's own + // destination, a top-level path rather than nested under a + // (handle, slug) — a cancel token names the Event directly, so + // there is no handle/slug to resolve through. chi ranks a + // static segment ahead of the dynamic {handle} route below it, + // so this can never be shadowed by a Handle claim (also refused + // outright at claim time — ReservedHandles). + r.Post("/cancel-booking", publicBookingHandler.Cancel) + + // The public index page (#325, ADR-0084): the derived rendering + // of an owner's Public Booking Links at /:handle, one path + // segment shallower than the link routes above — no ambiguity + // with them, since chi dispatches on segment count. + r.Get("/{handle}", publicBookingHandler.Index) + }) }) // CalDAV (ADR-0023, ADR-0024): mounted ahead of the SPA catch-all below and diff --git a/server/internal/router/router_test.go b/server/internal/router/router_test.go index 50588db..d88542b 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.TaskListHandler, a.TaskHandler, a.ConnectionHandler, a.AvailabilityScheduleHandler, a.BookingLinkHandler, a.PublicBookingHandler, 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/account.go b/server/internal/service/account.go index 2655bb2..86cfa6f 100644 --- a/server/internal/service/account.go +++ b/server/internal/service/account.go @@ -67,11 +67,12 @@ type AccountService struct { calendarRepo *repository.CalendarRepository shareRepo *repository.CalendarShareRepository workspaceRepo *repository.WorkspaceRepository + bookingLinks *repository.BookingLinkRepository workspaces *WorkspaceService } -func NewAccountService(db *sql.DB, users *repository.UserRepository, sessions *repository.SessionRepository, calendarRepo *repository.CalendarRepository, shareRepo *repository.CalendarShareRepository, workspaceRepo *repository.WorkspaceRepository, workspaces *WorkspaceService) *AccountService { - return &AccountService{db: db, users: users, sessions: sessions, calendarRepo: calendarRepo, shareRepo: shareRepo, workspaceRepo: workspaceRepo, workspaces: workspaces} +func NewAccountService(db *sql.DB, users *repository.UserRepository, sessions *repository.SessionRepository, calendarRepo *repository.CalendarRepository, shareRepo *repository.CalendarShareRepository, workspaceRepo *repository.WorkspaceRepository, bookingLinks *repository.BookingLinkRepository, workspaces *WorkspaceService) *AccountService { + return &AccountService{db: db, users: users, sessions: sessions, calendarRepo: calendarRepo, shareRepo: shareRepo, workspaceRepo: workspaceRepo, bookingLinks: bookingLinks, workspaces: workspaces} } // SetDisabled disables or re-activates the caller's own account (ADR-0044). @@ -203,6 +204,18 @@ func (s *AccountService) Delete(ctx context.Context, userID int64, dispositions } return repository.WithTx(ctx, s.db, func(tx *sql.Tx) error { + // A Booking Link has no transfer disposition of its own (#322, + // ADR-0084) — it simply dies with its owner, and must go before + // applyDispositions below: a DispositionDelete on a Calendar this + // User's own link still points at would otherwise trip + // book_into_calendar_id's FK guard (ErrCalendarReferenced), since + // deleting the users row itself — the moment that FK would clear on + // its own via cascade — hasn't happened yet at this point in the + // transaction. + if err := s.bookingLinks.WithTx(tx).DeleteAllForUser(ctx, userID); err != nil { + return fmt.Errorf("delete booking links: %w", err) + } + if err := applyDispositions(ctx, tx, s.calendarRepo, userID, dispositions); err != nil { return err } 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/auth.go b/server/internal/service/auth.go index 6b7c6a2..4f29d4b 100644 --- a/server/internal/service/auth.go +++ b/server/internal/service/auth.go @@ -6,6 +6,7 @@ import ( "database/sql" "errors" "fmt" + "strings" "golang.org/x/crypto/bcrypt" @@ -83,6 +84,16 @@ var ( // succeeds regardless, since it's how a fresh instance without // INITIAL_EMAIL/INITIAL_PASSWORD set gets bootstrapped. ErrSignupsDisabled = errors.New("self-registration is disabled on this instance") + // ErrInvalidHandle is returned by UpdateHandle when handle fails + // validateHandle — empty, over maxHandleLength, or outside handlePattern's + // charset (#321, ADR-0084). + ErrInvalidHandle = errors.New("handle must be 1-39 characters, lowercase letters, digits and single hyphens only, and may not start or end with a hyphen") + // ErrHandleReserved is returned by UpdateHandle when handle matches a + // word ReservedHandles reserves for the app's own routes (#321, ADR-0084). + ErrHandleReserved = errors.New("handle is reserved and cannot be claimed") + // ErrHandleTaken mirrors repository.ErrHandleTaken so handlers only + // import the service package's sentinels. + ErrHandleTaken = repository.ErrHandleTaken ) // validDefaultViews are the Active views a Default view may seed (ADR-0039). @@ -385,6 +396,61 @@ func (s *AuthService) UpdateName(ctx context.Context, userID int64, name string) return user, nil } +// UpdateHandle claims or changes the caller's own Handle — a User's public +// name (#321, ADR-0084). Validated the same way regardless of whether this +// is a first claim or a rename; the caller is responsible for warning that +// a rename breaks every URL published under the old one, since nothing is +// published under one yet in this codebase (that's #322/#324's job). +func (s *AuthService) UpdateHandle(ctx context.Context, userID int64, handle string) (repository.User, error) { + normalized, err := validateHandle(handle) + if err != nil { + return repository.User{}, err + } + + user, err := s.users.UpdateHandle(ctx, userID, normalized) + if err != nil { + if errors.Is(err, repository.ErrHandleTaken) { + return repository.User{}, ErrHandleTaken + } + return repository.User{}, fmt.Errorf("update handle: %w", err) + } + return user, nil +} + +// SuggestHandle derives a Handle suggestion for userID from their own +// Email's local part (#321, ADR-0084) — sanitized to handlePattern's own +// character set, and disambiguated with a numeric suffix against both +// ReservedHandles and every Handle already claimed, until a free one is +// found. Read-only: claiming the suggestion is a separate UpdateHandle call, +// so two callers racing for the same suggestion can never both "succeed" at +// this step — only one of them wins UpdateHandle's own uniqueness check. +func (s *AuthService) SuggestHandle(ctx context.Context, userID int64) (string, error) { + user, err := s.users.GetByID(ctx, userID) + if err != nil { + return "", fmt.Errorf("get user: %w", err) + } + + localPart, _, _ := strings.Cut(user.Email, "@") + base := sanitizeHandleBase(localPart) + if base == "" { + base = "user" + } + + candidate := base + for suffix := 2; ; suffix++ { + if !IsReservedHandle(candidate) { + _, err := s.users.GetByHandle(ctx, candidate) + if errors.Is(err, repository.ErrNotFound) { + return candidate, nil + } + if err != nil { + return "", fmt.Errorf("check handle availability: %w", err) + } + } + candidate = appendHandleSuffix(base, suffix) + } +} + // UpdateSyncedDeviceReminders sets userID's "let my synced devices show // reminder pop-ups (disable in-app reminder notifications)" preference // (ADR-0027). diff --git a/server/internal/service/auth_booking_cancel_token.go b/server/internal/service/auth_booking_cancel_token.go new file mode 100644 index 0000000..7c2c68c --- /dev/null +++ b/server/internal/service/auth_booking_cancel_token.go @@ -0,0 +1,58 @@ +package service + +import ( + "errors" + "fmt" + + "github.com/golang-jwt/jwt/v5" +) + +// bookingCancelPurpose is bookingCancelClaims' own "purpose" value — see +// connectStatePurpose's doc comment (auth_connect_state.go) for why the +// claim exists: without it, any other token this service signs under the +// same jwtSecret would parse as a valid cancel token too. +const bookingCancelPurpose = "booking_cancel" + +// bookingCancelClaims is the JWT payload IssueBookingCancelToken mints and +// ParseBookingCancelToken validates. Subject is the booked Event's own id — +// this is ADR-0087's "an HMAC over the Event id" — and carries no +// expiration: the link is only ever refused by Cancel checking the Event's +// own start against now, not by the token going stale on its own, so a +// visitor who waits weeks to cancel still can. +type bookingCancelClaims struct { + jwt.RegisteredClaims + Purpose string `json:"purpose"` +} + +// IssueBookingCancelToken mints the signed cancel link a booking +// confirmation carries (#327, ADR-0087). Reuses AuthService's own +// jwtSecret rather than inventing a second signing key, mirroring +// IssueConnectState. +func (s *AuthService) IssueBookingCancelToken(eventID string) (string, error) { + claims := bookingCancelClaims{ + RegisteredClaims: jwt.RegisteredClaims{Subject: eventID}, + Purpose: bookingCancelPurpose, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret) +} + +// ParseBookingCancelToken validates a cancel token IssueBookingCancelToken +// minted and returns the Event id it was issued for. +func (s *AuthService) ParseBookingCancelToken(token string) (string, error) { + claims := &bookingCancelClaims{} + + _, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return s.jwtSecret, nil + }) + if err != nil { + return "", fmt.Errorf("parse booking cancel token: %w", err) + } + if claims.Purpose != bookingCancelPurpose { + return "", errors.New("token is not a booking cancel token") + } + + return claims.Subject, nil +} diff --git a/server/internal/service/auth_test.go b/server/internal/service/auth_test.go index f1d71e1..c6b32fa 100644 --- a/server/internal/service/auth_test.go +++ b/server/internal/service/auth_test.go @@ -7,6 +7,7 @@ import ( "sync" "testing" "time" + "unicode/utf8" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" @@ -1035,6 +1036,267 @@ func TestUpdateEmail_DuplicateReturnsErrEmailTaken(t *testing.T) { } } +// TestUpdateHandle_ClaimsAValidHandle covers #321, ADR-0084: a User has no +// Handle until they claim one. +func TestUpdateHandle_ClaimsAValidHandle(t *testing.T) { + svc := newTestAuthService(t, "admin", "admin") + ctx := context.Background() + user, _, err := svc.Bootstrap(ctx) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + if user.Handle != nil { + t.Fatalf("expected a fresh User to have no Handle, got %v", *user.Handle) + } + + updated, err := svc.UpdateHandle(ctx, user.ID, "damir") + if err != nil { + t.Fatalf("update handle: %v", err) + } + if updated.Handle == nil || *updated.Handle != "damir" { + t.Fatalf("expected handle %q, got %+v", "damir", updated.Handle) + } +} + +// TestUpdateHandle_FoldsCase covers ADR-0084's "compared case-insensitively +// on the same terms as Email" — the app folds case on write, the same +// belt-and-suspenders normalizeEmail already applies (ADR-0058). +func TestUpdateHandle_FoldsCase(t *testing.T) { + svc := newTestAuthService(t, "admin", "admin") + ctx := context.Background() + user, _, err := svc.Bootstrap(ctx) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + + updated, err := svc.UpdateHandle(ctx, user.ID, " DaMir ") + if err != nil { + t.Fatalf("update handle: %v", err) + } + if updated.Handle == nil || *updated.Handle != "damir" { + t.Fatalf("expected handle folded to %q, got %+v", "damir", updated.Handle) + } +} + +func TestUpdateHandle_RejectsInvalidCharacters(t *testing.T) { + svc := newTestAuthService(t, "admin", "admin") + ctx := context.Background() + user, _, err := svc.Bootstrap(ctx) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + + cases := []string{"Damir_H", "-damir", "damir-", "da--mir", "da mir", "", " "} + for _, handle := range cases { + if _, err := svc.UpdateHandle(ctx, user.ID, handle); !errors.Is(err, ErrInvalidHandle) { + t.Fatalf("handle %q: expected ErrInvalidHandle, got %v", handle, err) + } + } +} + +func TestUpdateHandle_RejectsTooLong(t *testing.T) { + svc := newTestAuthService(t, "admin", "admin") + ctx := context.Background() + user, _, err := svc.Bootstrap(ctx) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + + tooLong := strings.Repeat("a", maxHandleLength+1) + if _, err := svc.UpdateHandle(ctx, user.ID, tooLong); !errors.Is(err, ErrInvalidHandle) { + t.Fatalf("expected ErrInvalidHandle, got %v", err) + } +} + +// TestUpdateHandle_RejectsEveryReservedWord covers the AC's exact list. +func TestUpdateHandle_RejectsEveryReservedWord(t *testing.T) { + svc := newTestAuthService(t, "admin", "admin") + ctx := context.Background() + user, _, err := svc.Bootstrap(ctx) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + + for word := range ReservedHandles { + if _, err := svc.UpdateHandle(ctx, user.ID, word); !errors.Is(err, ErrHandleReserved) { + t.Fatalf("word %q: expected ErrHandleReserved, got %v", word, err) + } + } +} + +func TestUpdateHandle_DuplicateReturnsErrHandleTaken(t *testing.T) { + svc := newTestAuthService(t, "admin", "admin") + ctx := context.Background() + user, _, err := svc.Bootstrap(ctx) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + bob, err := svc.users.Create(ctx, "bob", "bob@example.com", "hash", false) + if err != nil { + t.Fatalf("create bob: %v", err) + } + if _, err := svc.UpdateHandle(ctx, bob.ID, "bob"); err != nil { + t.Fatalf("bob claims handle: %v", err) + } + + if _, err := svc.UpdateHandle(ctx, user.ID, "bob"); !errors.Is(err, ErrHandleTaken) { + t.Fatalf("expected ErrHandleTaken, got %v", err) + } + // Case-insensitively too. + if _, err := svc.UpdateHandle(ctx, user.ID, "BOB"); !errors.Is(err, ErrHandleTaken) { + t.Fatalf("expected ErrHandleTaken for a differently-cased duplicate, got %v", err) + } +} + +// TestUpdateHandle_ReleasedHandleIsImmediatelyClaimable covers ADR-0084's +// "a released Handle can immediately be claimed by another User" — renaming +// bob off "bob" frees it up for alice with no cooldown. +func TestUpdateHandle_ReleasedHandleIsImmediatelyClaimable(t *testing.T) { + svc := newTestAuthService(t, "admin", "admin") + ctx := context.Background() + alice, _, err := svc.Bootstrap(ctx) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + bob, err := svc.users.Create(ctx, "bob", "bob@example.com", "hash", false) + if err != nil { + t.Fatalf("create bob: %v", err) + } + if _, err := svc.UpdateHandle(ctx, bob.ID, "bob"); err != nil { + t.Fatalf("bob claims handle: %v", err) + } + if _, err := svc.UpdateHandle(ctx, bob.ID, "bobby"); err != nil { + t.Fatalf("bob renames: %v", err) + } + + updated, err := svc.UpdateHandle(ctx, alice.ID, "bob") + if err != nil { + t.Fatalf("expected the released handle to be immediately claimable, got %v", err) + } + if updated.Handle == nil || *updated.Handle != "bob" { + t.Fatalf("expected alice to hold %q, got %+v", "bob", updated.Handle) + } +} + +func TestSuggestHandle_DerivesFromEmailLocalPart(t *testing.T) { + svc := newTestAuthService(t, "admin", "admin") + ctx := context.Background() + user, err := svc.users.Create(ctx, "Damir", "damir@example.com", "hash", false) + if err != nil { + t.Fatalf("create user: %v", err) + } + + suggestion, err := svc.SuggestHandle(ctx, user.ID) + if err != nil { + t.Fatalf("suggest handle: %v", err) + } + if suggestion != "damir" { + t.Fatalf("expected suggestion %q, got %q", "damir", suggestion) + } +} + +// TestSuggestHandle_SanitizesTheLocalPart covers dots/plusses/underscores an +// email local part may carry that a Handle's own charset forbids. +func TestSuggestHandle_SanitizesTheLocalPart(t *testing.T) { + svc := newTestAuthService(t, "admin", "admin") + ctx := context.Background() + user, err := svc.users.Create(ctx, "Damir", "damir.h+work@example.com", "hash", false) + if err != nil { + t.Fatalf("create user: %v", err) + } + + suggestion, err := svc.SuggestHandle(ctx, user.ID) + if err != nil { + t.Fatalf("suggest handle: %v", err) + } + if suggestion != "damir-h-work" { + t.Fatalf("expected suggestion %q, got %q", "damir-h-work", suggestion) + } +} + +// TestSuggestHandle_TakenSuggestionGetsANumericSuffix covers the AC line +// directly: "a taken suggestion is offered with a numeric suffix". +func TestSuggestHandle_TakenSuggestionGetsANumericSuffix(t *testing.T) { + svc := newTestAuthService(t, "admin", "admin") + ctx := context.Background() + alice, err := svc.users.Create(ctx, "Damir", "damir@example.com", "hash", false) + if err != nil { + t.Fatalf("create alice: %v", err) + } + if _, err := svc.UpdateHandle(ctx, alice.ID, "damir"); err != nil { + t.Fatalf("alice claims damir: %v", err) + } + bob, err := svc.users.Create(ctx, "Damir", "damir@another.example.com", "hash", false) + if err != nil { + t.Fatalf("create bob: %v", err) + } + + suggestion, err := svc.SuggestHandle(ctx, bob.ID) + if err != nil { + t.Fatalf("suggest handle: %v", err) + } + if suggestion != "damir-2" { + t.Fatalf("expected suggestion %q, got %q", "damir-2", suggestion) + } +} + +// TestSuggestHandle_NeverExceedsMaxLengthWhenBaseIsAlreadyAtTheLimit covers +// a real bug caught in review: a sanitized base already at maxHandleLength +// (39) that turns out to be taken must not produce a numeric-suffixed +// suggestion over that length, and must not loop forever truncating the +// suffix straight back off again. +func TestSuggestHandle_NeverExceedsMaxLengthWhenBaseIsAlreadyAtTheLimit(t *testing.T) { + svc := newTestAuthService(t, "admin", "admin") + ctx := context.Background() + + maxLengthBase := strings.Repeat("a", maxHandleLength) + holder, err := svc.users.Create(ctx, "Holder", "holder@example.com", "hash", false) + if err != nil { + t.Fatalf("create holder: %v", err) + } + if _, err := svc.UpdateHandle(ctx, holder.ID, maxLengthBase); err != nil { + t.Fatalf("holder claims max-length handle: %v", err) + } + + user, err := svc.users.Create(ctx, "Claimant", maxLengthBase+"@example.com", "hash", false) + if err != nil { + t.Fatalf("create user: %v", err) + } + + suggestion, err := svc.SuggestHandle(ctx, user.ID) + if err != nil { + t.Fatalf("suggest handle: %v", err) + } + if utf8.RuneCountInString(suggestion) > maxHandleLength { + t.Fatalf("expected suggestion to respect maxHandleLength (%d), got %q (%d)", maxHandleLength, suggestion, len(suggestion)) + } + if suggestion == maxLengthBase { + t.Fatalf("expected a suggestion distinct from the already-taken base, got %q", suggestion) + } + if _, err := validateHandle(suggestion); err != nil { + t.Fatalf("expected the suggestion to pass validateHandle, got %v", err) + } +} + +// TestSuggestHandle_SkipsAReservedBase covers a local part that happens to +// collide with a reserved word, e.g. an admin@ address. +func TestSuggestHandle_SkipsAReservedBase(t *testing.T) { + svc := newTestAuthService(t, "admin", "admin") + ctx := context.Background() + user, err := svc.users.Create(ctx, "Admin", "admin@another.example.com", "hash", false) + if err != nil { + t.Fatalf("create user: %v", err) + } + + suggestion, err := svc.SuggestHandle(ctx, user.ID) + if err != nil { + t.Fatalf("suggest handle: %v", err) + } + if suggestion != "admin-2" { + t.Fatalf("expected suggestion %q, got %q", "admin-2", suggestion) + } +} + func TestBootstrap_FirstUserDefaultsToMondayWeekStart(t *testing.T) { svc := newTestAuthService(t, "admin", "admin") ctx := context.Background() diff --git a/server/internal/service/auth_validation.go b/server/internal/service/auth_validation.go index 8502c0b..2799f13 100644 --- a/server/internal/service/auth_validation.go +++ b/server/internal/service/auth_validation.go @@ -3,6 +3,7 @@ package service import ( "net/mail" "regexp" + "strconv" "strings" "unicode" "unicode/utf8" @@ -22,6 +23,102 @@ const maxNameLength = 100 // login identifier compared on every Login and AppPasswordService.Authenticate. const maxEmailLength = 254 +// maxHandleLength bounds validateHandle (#321, ADR-0084) — long enough for +// a sanitized email local part plus a numeric disambiguation suffix, +// short enough to stay readable in a shared URL (`/:handle`). +const maxHandleLength = 39 + +// handlePattern is a Handle's own character set (ADR-0084): lowercase +// letters, digits and single hyphens between them. A Handle is a raw URL +// path segment rather than free text — there is no encoding step between +// what a User types and what appears in the address bar — so it excludes +// anything that isn't safe or legible there outright, rather than escaping +// it. The pattern itself forbids a leading, trailing, or doubled hyphen, +// which is also what keeps sanitizeHandleBase's output always valid input +// to this same check. +var handlePattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +// normalizeHandle trims and lowercases a Handle the same way normalizeEmail +// folds an email — what makes a stored Handle comparable by plain Go string +// equality, with idx_users_handle's own COLLATE NOCASE only helping at the +// SQL level. +func normalizeHandle(handle string) string { + return strings.ToLower(strings.TrimSpace(handle)) +} + +// validateHandle normalizes handle and checks it against the one set of +// rules shared by every path that claims or changes one — AuthService. +// UpdateHandle and SuggestHandle's own availability loop (#321, ADR-0084). +func validateHandle(handle string) (string, error) { + handle = normalizeHandle(handle) + if handle == "" { + return "", ErrInvalidHandle + } + if utf8.RuneCountInString(handle) > maxHandleLength { + return "", ErrInvalidHandle + } + if !handlePattern.MatchString(handle) { + return "", ErrInvalidHandle + } + if IsReservedHandle(handle) { + return "", ErrHandleReserved + } + return handle, nil +} + +// sanitizeHandleBase turns raw (an Email's local part) into a string +// handlePattern would accept, without checking length or reservation — +// SuggestHandle's own availability loop still runs the result through +// those. Every rune outside handlePattern's charset becomes a hyphen; +// consecutive separators (e.g. "john.doe+work" having both a dot and a +// plus) collapse to one, and a leading or trailing one is dropped, so this +// reads "john-doe-work" rather than "john-doe--work-". +func sanitizeHandleBase(raw string) string { + lower := strings.ToLower(raw) + var b strings.Builder + lastWasHyphen := true // suppresses a leading hyphen the same way it suppresses a run of them + for _, r := range lower { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + lastWasHyphen = false + continue + } + if !lastWasHyphen { + b.WriteByte('-') + lastWasHyphen = true + } + } + base := strings.TrimSuffix(b.String(), "-") + + // Truncating a rune-safe base can leave a trailing hyphen (e.g. a cut + // landing right after "john-"); trim it again so the result always + // matches handlePattern on its own, before any numeric suffix is + // appended. + if utf8.RuneCountInString(base) > maxHandleLength { + runes := []rune(base) + base = strings.TrimSuffix(string(runes[:maxHandleLength]), "-") + } + return base +} + +// appendHandleSuffix appends "-" to base for SuggestHandle's +// availability loop, truncating base first to leave exact room for the +// marker so the result never exceeds maxHandleLength (#321). Without the +// truncation, a base already at maxHandleLength (sanitizeHandleBase's own +// ceiling) would either produce a candidate validateHandle refuses as too +// long, or — truncated back down naively, with no room reserved for the +// marker — collapse straight back to the untouched, already-taken base and +// loop forever. +func appendHandleSuffix(base string, suffix int) string { + marker := "-" + strconv.Itoa(suffix) + maxBaseLen := maxHandleLength - len(marker) + if utf8.RuneCountInString(base) > maxBaseLen { + runes := []rune(base) + base = strings.TrimSuffix(string(runes[:maxBaseLen]), "-") + } + return base + marker +} + // maxPasswordBytes is bcrypt's own limit (golang.org/x/crypto/bcrypt, // bcrypt.go, #241) — GenerateFromPassword returns the opaque // ErrPasswordTooLong for anything over it, and every caller here wraps that diff --git a/server/internal/service/availability_schedule.go b/server/internal/service/availability_schedule.go new file mode 100644 index 0000000..4c87300 --- /dev/null +++ b/server/internal/service/availability_schedule.go @@ -0,0 +1,253 @@ +package service + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/XiovV/calich/server/internal/repository" +) + +var ( + // ErrInvalidScheduleName is returned by Create and Update when name is + // empty. + ErrInvalidScheduleName = errors.New("availability schedule name must not be empty") + + // ErrInvalidTimezone is returned by Create and Update when tzid is not a + // loadable IANA zone (ADR-0085 — a Schedule's zone is an Anchor zone in + // everything but name, so it must resolve the same way an Event's does). + ErrInvalidTimezone = errors.New("invalid IANA timezone") + + // ErrInvalidAvailabilityRange is returned by Create and Update when a + // range's weekday is outside 0-6, or its start/end minute-of-day pair is + // out of bounds or non-increasing. + ErrInvalidAvailabilityRange = errors.New("invalid availability schedule range") + + // ErrScheduleReferenced mirrors repository.ErrScheduleReferenced so + // handlers only import the service package's sentinels (#322, ADR-0085). + ErrScheduleReferenced = repository.ErrScheduleReferenced +) + +const ( + minWeekday = 0 + maxWeekday = 6 + maxRangeMinute = 24 * 60 + + // defaultScheduleName and fallbackTzid seed the first Schedule an ensureDefault + // auto-creates (ADR-0085). + defaultScheduleName = "Default" + fallbackTzid = "Etc/UTC" + + defaultWorkingHoursStart = 9 * 60 + defaultWorkingHoursEnd = 17 * 60 +) + +// seedWeekdays is Mon-Fri (1-5, matching users.week_start's Sunday=0 +// convention) — the days ADR-0085's seeding rule shades, whether copied from +// Working hours or falling back to 09:00-17:00. +var seedWeekdays = []int{1, 2, 3, 4, 5} + +// AvailabilityScheduleService creates, lists, updates and deletes +// Availability Schedules (ADR-0085). Private to the caller's own userID — +// unlike CalendarSetService and TaskListService there is no Workspace +// scoping at all, since a Schedule belongs to the User outright and is +// reusable across every Workspace a Booking Link of theirs might write +// into. Every method resolves by (id, userID) at the repository layer and +// surfaces a mismatch as repository.ErrNotFound, never a distinguishable +// "forbidden". +type AvailabilityScheduleService struct { + db *sql.DB + schedules *repository.AvailabilityScheduleRepository + users *repository.UserRepository +} + +func NewAvailabilityScheduleService(db *sql.DB, schedules *repository.AvailabilityScheduleRepository, users *repository.UserRepository) *AvailabilityScheduleService { + return &AvailabilityScheduleService{db: db, schedules: schedules, users: users} +} + +// ListForUser returns every Schedule userID owns, first making sure at least +// one exists (ensureDefault) — the lazy creation ADR-0085 describes as +// happening "at that User's first Booking Link" happens here instead, since +// nothing in this codebase creates a Booking Link yet (#322): the first time +// a caller asks to see their Schedules is the earliest available trigger. +// tzHint is the caller's own browser-detected IANA zone, used only to seed +// the Default Schedule's timezone the one time it doesn't exist yet; +// already-existing Schedules are never touched by it. +func (s *AvailabilityScheduleService) ListForUser(ctx context.Context, userID int64, tzHint string) ([]repository.AvailabilitySchedule, error) { + if err := s.ensureDefault(ctx, userID, tzHint); err != nil { + return nil, fmt.Errorf("ensure default availability schedule: %w", err) + } + + schedules, err := s.schedules.ListForUser(ctx, userID) + if err != nil { + return nil, fmt.Errorf("list availability schedules: %w", err) + } + return schedules, nil +} + +// ensureDefault seeds userID's first Schedule, named "Default", from their +// Working hours Preference — Mon-Fri at its start/end when set, Mon-Fri +// 09:00-17:00 otherwise (ADR-0085) — the moment they have none at all. +// Seeded once: a second call is a no-op, so editing Working hours afterwards +// never reaches a Schedule, ever. +func (s *AvailabilityScheduleService) ensureDefault(ctx context.Context, userID int64, tzHint string) error { + count, err := s.schedules.CountForUser(ctx, userID) + if err != nil { + return fmt.Errorf("count availability schedules: %w", err) + } + if count > 0 { + return nil + } + + user, err := s.users.GetByID(ctx, userID) + if err != nil { + return fmt.Errorf("get user: %w", err) + } + + start, end := defaultWorkingHoursStart, defaultWorkingHoursEnd + if user.WorkingHoursStart != nil && user.WorkingHoursEnd != nil { + start, end = *user.WorkingHoursStart, *user.WorkingHoursEnd + } + + // tzHint is trusted only if it actually resolves — an empty or garbled + // browser-detected zone falls back to Etc/UTC rather than seeding a + // Schedule that can never derive a slot (#323). + tzid := fallbackTzid + if strings.TrimSpace(tzHint) != "" { + if _, err := time.LoadLocation(tzHint); err == nil { + tzid = tzHint + } + } + + ranges := make([]repository.AvailabilityRange, len(seedWeekdays)) + for i, weekday := range seedWeekdays { + ranges[i] = repository.AvailabilityRange{Weekday: weekday, StartMinute: start, EndMinute: end} + } + + return repository.WithTx(ctx, s.db, func(tx *sql.Tx) error { + txSchedules := s.schedules.WithTx(tx) + schedule, err := txSchedules.CreateSchedule(ctx, userID, defaultScheduleName, tzid) + if err != nil { + return err + } + return txSchedules.InsertRanges(ctx, schedule.ID, ranges) + }) +} + +// Create makes a new Schedule named name, in timezone tzid, owned by userID, +// with ranges as its initial weekly pattern — an empty slice is legal +// (ADR-0085's "an empty Schedule is legal" rule). +func (s *AvailabilityScheduleService) Create(ctx context.Context, userID int64, name, tzid string, ranges []repository.AvailabilityRange) (repository.AvailabilitySchedule, error) { + name = strings.TrimSpace(name) + if name == "" { + return repository.AvailabilitySchedule{}, ErrInvalidScheduleName + } + if err := validateTzid(tzid); err != nil { + return repository.AvailabilitySchedule{}, err + } + if err := validateRanges(ranges); err != nil { + return repository.AvailabilitySchedule{}, err + } + + var created repository.AvailabilitySchedule + err := repository.WithTx(ctx, s.db, func(tx *sql.Tx) error { + txSchedules := s.schedules.WithTx(tx) + + schedule, err := txSchedules.CreateSchedule(ctx, userID, name, tzid) + if err != nil { + return err + } + if err := txSchedules.InsertRanges(ctx, schedule.ID, ranges); err != nil { + return err + } + + created, err = txSchedules.GetByID(ctx, schedule.ID, userID) + return err + }) + if err != nil { + return repository.AvailabilitySchedule{}, fmt.Errorf("create availability schedule: %w", err) + } + return created, nil +} + +// Update replaces id's name, timezone and entire weekly range set at once, +// scoped to userID. Ranges are wholesale-replaced (delete then re-insert in +// the same transaction) rather than diffed — the Settings Section edits a +// Schedule as one form, so there is no partial-range update to preserve. +func (s *AvailabilityScheduleService) Update(ctx context.Context, userID, id int64, name, tzid string, ranges []repository.AvailabilityRange) (repository.AvailabilitySchedule, error) { + name = strings.TrimSpace(name) + if name == "" { + return repository.AvailabilitySchedule{}, ErrInvalidScheduleName + } + if err := validateTzid(tzid); err != nil { + return repository.AvailabilitySchedule{}, err + } + if err := validateRanges(ranges); err != nil { + return repository.AvailabilitySchedule{}, err + } + + var updated repository.AvailabilitySchedule + err := repository.WithTx(ctx, s.db, func(tx *sql.Tx) error { + txSchedules := s.schedules.WithTx(tx) + + if err := txSchedules.UpdateFields(ctx, id, userID, name, tzid); err != nil { + return err + } + if err := txSchedules.DeleteRanges(ctx, id); err != nil { + return err + } + if err := txSchedules.InsertRanges(ctx, id, ranges); err != nil { + return err + } + + var err error + updated, err = txSchedules.GetByID(ctx, id, userID) + return err + }) + if err != nil { + return repository.AvailabilitySchedule{}, fmt.Errorf("update availability schedule: %w", err) + } + return updated, nil +} + +// Delete removes id outright, scoped to userID. +func (s *AvailabilityScheduleService) Delete(ctx context.Context, userID, id int64) error { + if err := s.schedules.Delete(ctx, id, userID); err != nil { + if errors.Is(err, repository.ErrScheduleReferenced) { + return ErrScheduleReferenced + } + return fmt.Errorf("delete availability schedule: %w", err) + } + return nil +} + +// validateTzid requires tzid to be a loadable IANA zone — validated eagerly +// at this boundary (a User picking one in Settings) since nothing else in +// this codebase checks it before it is stored. +func validateTzid(tzid string) error { + if strings.TrimSpace(tzid) == "" { + return ErrInvalidTimezone + } + if _, err := time.LoadLocation(tzid); err != nil { + return ErrInvalidTimezone + } + return nil +} + +func validateRanges(ranges []repository.AvailabilityRange) error { + for _, rng := range ranges { + if rng.Weekday < minWeekday || rng.Weekday > maxWeekday { + return ErrInvalidAvailabilityRange + } + if rng.StartMinute < 0 || rng.StartMinute >= maxRangeMinute { + return ErrInvalidAvailabilityRange + } + if rng.EndMinute <= rng.StartMinute || rng.EndMinute > maxRangeMinute { + return ErrInvalidAvailabilityRange + } + } + return nil +} diff --git a/server/internal/service/booking_link.go b/server/internal/service/booking_link.go new file mode 100644 index 0000000..056f184 --- /dev/null +++ b/server/internal/service/booking_link.go @@ -0,0 +1,443 @@ +package service + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/XiovV/calich/server/internal/repository" +) + +var ( + // ErrInvalidBookingLinkTitle is returned by Create and Update when Title is empty. + ErrInvalidBookingLinkTitle = errors.New("booking link title must not be empty") + + // ErrInvalidSlug is returned by Create and Update when Slug fails + // validateSlug — empty, over maxSlugLength, or outside slugPattern's + // charset (#322, ADR-0084). + ErrInvalidSlug = errors.New("slug must be 1-39 characters, lowercase letters, digits and single hyphens only, and may not start or end with a hyphen") + + // ErrInvalidDuration is returned by Create and Update when + // DurationMinutes is outside minDurationMinutes..maxDurationMinutes. + ErrInvalidDuration = errors.New("duration must be between 1 and 1440 minutes") + + // ErrInvalidVisibility is returned by Create and Update when Visibility + // isn't one of public/private/paused (ADR-0087). + ErrInvalidVisibility = errors.New("visibility must be one of public, private, paused") + + // ErrInvalidMinimumNotice is returned by Create and Update when + // MinimumNoticeMinutes is negative. + ErrInvalidMinimumNotice = errors.New("minimum notice must not be negative") + + // ErrInvalidBookingHorizon is returned by Create and Update when + // BookingHorizonDays is under 1. + ErrInvalidBookingHorizon = errors.New("booking horizon must be at least 1 day") + + // ErrScheduleNotFound is returned by Create and Update when + // AvailabilityScheduleID doesn't name a Schedule the caller owns. + ErrScheduleNotFound = errors.New("availability schedule not found") + + // ErrBookIntoCalendarNotFound is returned by Create and Update when + // BookIntoCalendarID doesn't name a Calendar in the active Workspace + // the caller owns or can edit (#322's "Book-into is restricted to + // Calendars the User owns or can edit in that Workspace") — + // deliberately collapsing "doesn't exist" and "insufficient Access" + // into one answer, the same posture CalendarSetService.AddCalendar + // already takes toward a Calendar Set's own membership. + ErrBookIntoCalendarNotFound = errors.New("book-into calendar not found") + + // ErrCannotRemoveBookIntoCalendar is returned by RemoveConflictCalendar + // when calendarID is the link's own Book-into Calendar — permanently in + // the Conflict set and unremovable (ADR-0087). + ErrCannotRemoveBookIntoCalendar = errors.New("cannot remove the book-into calendar from the conflict set") + + // ErrSlugTaken mirrors repository.ErrSlugTaken so handlers only import + // the service package's sentinels. + ErrSlugTaken = repository.ErrSlugTaken +) + +// BookingLinkWrite is the Create/Update input (#322): every field Create +// and Update both take, gathered into one value the same way CalendarWrite +// already gathers a Calendar's — validated wholesale by validateWrite +// before either ever reaches the repository. +type BookingLinkWrite struct { + Title string + Slug string + DurationMinutes int + Visibility string + AvailabilityScheduleID int64 + BookIntoCalendarID string + Location string + Description string + MinimumNoticeMinutes int + BookingHorizonDays int + TasksInConflictSet bool +} + +// BookingLinkService creates, lists, updates, duplicates and deletes +// Booking Links (#322, ADR-0084, ADR-0087), and manages each one's Conflict +// set. Scoped to the caller's own (userID, workspaceID) on the same terms +// as TaskListService — every method resolves by (id, userID, workspaceID) +// at the repository layer and surfaces a mismatch as repository.ErrNotFound, +// never a distinguishable "forbidden". +type BookingLinkService struct { + db *sql.DB + links *repository.BookingLinkRepository + calendarRepo *repository.CalendarRepository + calendars *CalendarService + schedules *repository.AvailabilityScheduleRepository + eventRepo *repository.EventRepository + taskRepo *repository.TaskRepository + auth *AuthService +} + +func NewBookingLinkService(db *sql.DB, links *repository.BookingLinkRepository, calendarRepo *repository.CalendarRepository, calendars *CalendarService, schedules *repository.AvailabilityScheduleRepository, eventRepo *repository.EventRepository, taskRepo *repository.TaskRepository, auth *AuthService) *BookingLinkService { + return &BookingLinkService{db: db, links: links, calendarRepo: calendarRepo, calendars: calendars, schedules: schedules, eventRepo: eventRepo, taskRepo: taskRepo, auth: auth} +} + +// ListForUser returns every Booking Link userID owns inside workspaceID. +func (s *BookingLinkService) ListForUser(ctx context.Context, userID, workspaceID int64) ([]repository.BookingLink, error) { + links, err := s.links.ListForUser(ctx, userID, workspaceID) + if err != nil { + return nil, fmt.Errorf("list booking links: %w", err) + } + return links, nil +} + +// Create makes a new Booking Link owned by userID inside workspaceID. Its +// Conflict set is seeded automatically — every Calendar userID owns in +// workspaceID, plus the Book-into Calendar itself, pinned — since the +// create modal's face never asks for one (#322: Conflict set sits behind +// More options, and a link created without opening it must still be +// usable). Claims a Handle for userID if this turns out to be their first +// Booking Link and they have none yet (ADR-0084). +func (s *BookingLinkService) Create(ctx context.Context, userID, workspaceID int64, write BookingLinkWrite) (repository.BookingLink, error) { + fields, err := s.validateWrite(ctx, userID, workspaceID, write) + if err != nil { + return repository.BookingLink{}, err + } + + // Read before the transaction opens, not inside it: this app's test + // database caps the pool at one connection (testdb.go), so a read + // through s.calendarRepo's own, non-tx-bound *sql.DB while the + // transaction below already holds that one connection would block + // forever waiting for a second connection nothing will ever release. + seedIDs, err := s.seedConflictCalendarIDs(ctx, userID, workspaceID, fields.BookIntoCalendarID) + if err != nil { + return repository.BookingLink{}, err + } + + var created repository.BookingLink + err = repository.WithTx(ctx, s.db, func(tx *sql.Tx) error { + txLinks := s.links.WithTx(tx) + + link, err := txLinks.Create(ctx, userID, workspaceID, fields) + if err != nil { + return err + } + + for _, calendarID := range seedIDs { + if err := txLinks.AddConflictCalendar(ctx, link.ID, calendarID); err != nil { + return err + } + } + + created, err = txLinks.GetByID(ctx, link.ID, userID, workspaceID) + return err + }) + if err != nil { + return repository.BookingLink{}, s.translateWriteError(err, "create booking link") + } + + s.claimHandleIfNeeded(ctx, userID) + + return created, nil +} + +// Update replaces id's fields wholesale, scoped to userID and workspaceID. +// Never touches the Conflict set's Calendar membership — that's +// AddConflictCalendar/RemoveConflictCalendar's job — except to re-pin +// whichever Calendar Book-into now names, in case it just changed. +func (s *BookingLinkService) Update(ctx context.Context, userID, workspaceID, id int64, write BookingLinkWrite) (repository.BookingLink, error) { + fields, err := s.validateWrite(ctx, userID, workspaceID, write) + if err != nil { + return repository.BookingLink{}, err + } + + var updated repository.BookingLink + err = repository.WithTx(ctx, s.db, func(tx *sql.Tx) error { + txLinks := s.links.WithTx(tx) + + if _, err := txLinks.Update(ctx, id, userID, workspaceID, fields); err != nil { + return err + } + if err := txLinks.AddConflictCalendar(ctx, id, fields.BookIntoCalendarID); err != nil { + return err + } + + var err error + updated, err = txLinks.GetByID(ctx, id, userID, workspaceID) + return err + }) + if err != nil { + return repository.BookingLink{}, s.translateWriteError(err, "update booking link") + } + return updated, nil +} + +// Delete removes id outright, scoped to userID and workspaceID. Its +// Conflict set rows cascade. +func (s *BookingLinkService) Delete(ctx context.Context, userID, workspaceID, id int64) error { + if err := s.links.Delete(ctx, id, userID, workspaceID); err != nil { + return fmt.Errorf("delete booking link: %w", err) + } + return nil +} + +// Duplicate clones id into a new Booking Link (#322), deriving a fresh +// per-User-unique Slug ("-copy", then "-copy-2", ...) and Title +// (" (copy)"), and copying its Conflict set and +// Tasks-in-Conflict-set flag verbatim — unlike Create's fresh seed, a +// duplicate preserves whatever customization the original's Conflict set +// already carries. May also claim a Handle, on the same terms as Create. +func (s *BookingLinkService) Duplicate(ctx context.Context, userID, workspaceID, id int64) (repository.BookingLink, error) { + source, err := s.links.GetByID(ctx, id, userID, workspaceID) + if err != nil { + return repository.BookingLink{}, fmt.Errorf("get booking link: %w", err) + } + + slug, err := s.freeSlug(ctx, userID, truncateSlug(source.Slug+"-copy")) + if err != nil { + return repository.BookingLink{}, err + } + + fields := repository.BookingLinkFields{ + Title: source.Title + " (copy)", + Slug: slug, + DurationMinutes: source.DurationMinutes, + Visibility: source.Visibility, + AvailabilityScheduleID: source.AvailabilityScheduleID, + BookIntoCalendarID: source.BookIntoCalendarID, + Location: source.Location, + Description: source.Description, + MinimumNoticeMinutes: source.MinimumNoticeMinutes, + BookingHorizonDays: source.BookingHorizonDays, + TasksInConflictSet: source.TasksInConflictSet, + } + + var duplicated repository.BookingLink + err = repository.WithTx(ctx, s.db, func(tx *sql.Tx) error { + txLinks := s.links.WithTx(tx) + + link, err := txLinks.Create(ctx, userID, workspaceID, fields) + if err != nil { + return err + } + for _, calendarID := range source.ConflictCalendarIDs { + if err := txLinks.AddConflictCalendar(ctx, link.ID, calendarID); err != nil { + return err + } + } + + duplicated, err = txLinks.GetByID(ctx, link.ID, userID, workspaceID) + return err + }) + if err != nil { + return repository.BookingLink{}, s.translateWriteError(err, "duplicate booking link") + } + + s.claimHandleIfNeeded(ctx, userID) + + return duplicated, nil +} + +// AddConflictCalendar puts calendarID into id's Conflict set, scoped to +// userID and workspaceID. Validates that calendarID belongs to workspaceID +// and userID has at least Viewer Access to it, mirroring +// CalendarSetService.AddCalendar's own check — deriving availability (#323) +// only needs to read a Calendar's Events, not edit them. +func (s *BookingLinkService) AddConflictCalendar(ctx context.Context, userID, workspaceID, id int64, calendarID string) error { + if _, err := s.links.GetByID(ctx, id, userID, workspaceID); err != nil { + return fmt.Errorf("get booking link: %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.links.AddConflictCalendar(ctx, id, calendarID); err != nil { + return fmt.Errorf("add conflict calendar: %w", err) + } + return nil +} + +// RemoveConflictCalendar takes calendarID out of id's Conflict set, scoped +// like AddConflictCalendar. Refuses removing the Book-into Calendar +// (ErrCannotRemoveBookIntoCalendar) — it is permanently in the set and +// unremovable (ADR-0087). +func (s *BookingLinkService) RemoveConflictCalendar(ctx context.Context, userID, workspaceID, id int64, calendarID string) error { + link, err := s.links.GetByID(ctx, id, userID, workspaceID) + if err != nil { + return fmt.Errorf("get booking link: %w", err) + } + if link.BookIntoCalendarID == calendarID { + return ErrCannotRemoveBookIntoCalendar + } + + if err := s.links.RemoveConflictCalendar(ctx, id, calendarID); err != nil { + return fmt.Errorf("remove conflict calendar: %w", err) + } + return nil +} + +// validateWrite checks write against every Create/Update rule and resolves +// its two references (AvailabilityScheduleID, BookIntoCalendarID) against +// what userID actually owns or can reach in workspaceID, before either +// caller ever touches the repository. +func (s *BookingLinkService) validateWrite(ctx context.Context, userID, workspaceID int64, write BookingLinkWrite) (repository.BookingLinkFields, error) { + title := strings.TrimSpace(write.Title) + if title == "" { + return repository.BookingLinkFields{}, ErrInvalidBookingLinkTitle + } + + slug, err := validateSlug(write.Slug) + if err != nil { + return repository.BookingLinkFields{}, err + } + + if write.DurationMinutes < minDurationMinutes || write.DurationMinutes > maxDurationMinutes { + return repository.BookingLinkFields{}, ErrInvalidDuration + } + + visibility := strings.ToLower(strings.TrimSpace(write.Visibility)) + if !validVisibilities[visibility] { + return repository.BookingLinkFields{}, ErrInvalidVisibility + } + + if write.MinimumNoticeMinutes < 0 { + return repository.BookingLinkFields{}, ErrInvalidMinimumNotice + } + if write.BookingHorizonDays < 1 { + return repository.BookingLinkFields{}, ErrInvalidBookingHorizon + } + + if _, err := s.schedules.GetByID(ctx, write.AvailabilityScheduleID, userID); err != nil { + if errors.Is(err, repository.ErrNotFound) { + return repository.BookingLinkFields{}, ErrScheduleNotFound + } + return repository.BookingLinkFields{}, fmt.Errorf("get availability schedule: %w", err) + } + + access, calendar, err := s.calendars.Access(ctx, userID, write.BookIntoCalendarID) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return repository.BookingLinkFields{}, ErrBookIntoCalendarNotFound + } + return repository.BookingLinkFields{}, fmt.Errorf("resolve book-into calendar access: %w", err) + } + if !access.CanWrite() || calendar.WorkspaceID != workspaceID { + return repository.BookingLinkFields{}, ErrBookIntoCalendarNotFound + } + + return repository.BookingLinkFields{ + Title: title, + Slug: slug, + DurationMinutes: write.DurationMinutes, + Visibility: visibility, + AvailabilityScheduleID: write.AvailabilityScheduleID, + BookIntoCalendarID: write.BookIntoCalendarID, + Location: write.Location, + Description: write.Description, + MinimumNoticeMinutes: write.MinimumNoticeMinutes, + BookingHorizonDays: write.BookingHorizonDays, + TasksInConflictSet: write.TasksInConflictSet, + }, nil +} + +// seedConflictCalendarIDs is Create's default Conflict set (#322, ADR-0087): +// every Calendar userID owns in workspaceID, plus bookIntoCalendarID itself +// — present unconditionally, since Book-into is pinned and unremovable even +// on the rare create where it names a Calendar the caller can edit but +// doesn't own (an Editor Share). +func (s *BookingLinkService) seedConflictCalendarIDs(ctx context.Context, userID, workspaceID int64, bookIntoCalendarID string) ([]string, error) { + owned, err := s.calendarRepo.ListByUserAndWorkspace(ctx, userID, workspaceID) + if err != nil { + return nil, fmt.Errorf("list owned calendars: %w", err) + } + + ids := make([]string, 0, len(owned)+1) + seenBookInto := false + for _, c := range owned { + ids = append(ids, c.ID) + if c.ID == bookIntoCalendarID { + seenBookInto = true + } + } + if !seenBookInto { + ids = append(ids, bookIntoCalendarID) + } + return ids, nil +} + +// freeSlug returns the first of base, base-2, base-3, ... not already taken +// by userID, mirroring AuthService.SuggestHandle's own disambiguation loop +// but scoped per-User rather than instance-wide — a Slug is unique per +// User, not instance-wide (ADR-0084). +func (s *BookingLinkService) freeSlug(ctx context.Context, userID int64, base string) (string, error) { + candidate := base + for suffix := 2; ; suffix++ { + _, err := s.links.GetBySlug(ctx, userID, candidate) + if errors.Is(err, repository.ErrNotFound) { + return candidate, nil + } + if err != nil { + return "", fmt.Errorf("check slug availability: %w", err) + } + candidate = appendSlugSuffix(base, suffix) + } +} + +// claimHandleIfNeeded auto-claims a Handle for userID the moment their +// Booking Link count first becomes 1 and they have none yet (#322, +// ADR-0084's "creating a first Booking Link claims a Handle"). Best-effort: +// a failure here (an unlucky race against another claim, say) costs userID +// nothing but the auto-claim itself — the Booking Link this call followed +// already committed, and Settings → Account (#321) remains available to +// claim one by hand. Failing the whole create/duplicate over this secondary +// effect would be far more confusing than silently skipping it. +func (s *BookingLinkService) claimHandleIfNeeded(ctx context.Context, userID int64) { + count, err := s.links.CountForUser(ctx, userID) + if err != nil || count != 1 { + return + } + + user, err := s.auth.GetUser(ctx, userID) + if err != nil || user.Handle != nil { + return + } + + suggestion, err := s.auth.SuggestHandle(ctx, userID) + if err != nil { + return + } + _, _ = s.auth.UpdateHandle(ctx, userID, suggestion) +} + +// translateWriteError maps the sentinels Create/Update/Duplicate's +// transactions can surface into this package's own, so a handler never +// needs to import repository directly for these. +func (s *BookingLinkService) translateWriteError(err error, msg string) error { + if errors.Is(err, repository.ErrSlugTaken) { + return ErrSlugTaken + } + if errors.Is(err, repository.ErrNotFound) { + return repository.ErrNotFound + } + return fmt.Errorf("%s: %w", msg, err) +} diff --git a/server/internal/service/booking_link_slots.go b/server/internal/service/booking_link_slots.go new file mode 100644 index 0000000..3478bdf --- /dev/null +++ b/server/internal/service/booking_link_slots.go @@ -0,0 +1,258 @@ +package service + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/XiovV/calich/server/internal/availability" + "github.com/XiovV/calich/server/internal/recurrence" + "github.com/XiovV/calich/server/internal/repository" +) + +// ErrInvalidMonth is returned by DeriveSlotsForMonth when month is outside +// 1-12. +var ErrInvalidMonth = fmt.Errorf("month must be between 1 and 12") + +// DeriveSlotsForMonth answers "when is this person free" for one Booking +// Link, scoped to one calendar month (#323) — the endpoint the eventual +// public page (#324) will read with no Session at all. Derivation itself +// (internal/availability.DeriveSlots) is pure; this method is the seam that +// assembles its inputs from the database: the Availability Schedule's own +// ranges and timezone, and the Conflict set expanded into Busy intervals. +// now is passed in, not read from time.Now() here, so a caller (a test, or +// the eventual public handler) controls what "now" means for minimum +// notice and the booking horizon. +func (s *BookingLinkService) DeriveSlotsForMonth(ctx context.Context, userID, workspaceID, id int64, year int, month time.Month, now time.Time) ([]time.Time, error) { + // Validated before the repository lookup below, not after: this + // preserves DeriveSlotsForMonth's original priority (a malformed month + // is rejected before ever costing a query) now that the lookup and the + // derivation it feeds live in two separate functions. + if month < 1 || month > 12 { + return nil, ErrInvalidMonth + } + + link, err := s.links.GetByID(ctx, id, userID, workspaceID) + if err != nil { + return nil, fmt.Errorf("get booking link: %w", err) + } + + return s.DeriveSlotsForLinkAndMonth(ctx, link, year, month, now) +} + +// DeriveSlotsForLinkAndMonth is DeriveSlotsForMonth's own body, split out so +// a caller that has already resolved a Booking Link some other way — the +// public page (#324), which resolves one by (Handle, Slug) rather than by +// (id, userID, workspaceID) — can reuse the exact same derivation instead of +// a second implementation that could drift from this one. +func (s *BookingLinkService) DeriveSlotsForLinkAndMonth(ctx context.Context, link repository.BookingLink, year int, month time.Month, now time.Time) ([]time.Time, error) { + schedule, err := s.schedules.GetByID(ctx, link.AvailabilityScheduleID, link.UserID) + if err != nil { + return nil, fmt.Errorf("get availability schedule: %w", err) + } + return s.deriveSlotsWith(ctx, s.eventRepo, s.taskRepo, schedule, link, year, month, now) +} + +// SlotAvailable reports whether start is still exactly one of link's +// bookable slots for the calendar month it falls in (in schedule's own +// zone), re-derived through events/tasks rather than trusted from an +// earlier read (#326, ADR-0087). tx, when non-nil, re-derives against that +// transaction's own tx-bound Event/Task repositories — built here, from +// BookingLinkService's own eventRepo/taskRepo fields, rather than handed in +// by the caller — so Booking's write path (the one caller that passes one) +// can run this check and the insert it guards against the same snapshot, +// inside the very transaction its insert is about to run in, without +// reaching into this service's internals to do it. Nil reuses the pooled +// repositories instead, same as DeriveSlotsForLinkAndMonth. Reuses +// deriveSlotsWith wholesale, the exact derivation /slots itself answers +// with, so a booking can never be accepted by a rule its own listing +// wouldn't have offered. +func (s *BookingLinkService) SlotAvailable(ctx context.Context, tx *sql.Tx, schedule repository.AvailabilitySchedule, link repository.BookingLink, start, now time.Time) (bool, error) { + loc, err := time.LoadLocation(schedule.Tzid) + if err != nil { + return false, fmt.Errorf("load location %q: %w", schedule.Tzid, err) + } + local := start.In(loc) + + events, tasks := s.eventRepo, s.taskRepo + if tx != nil { + events, tasks = events.WithTx(tx), tasks.WithTx(tx) + } + + slots, err := s.deriveSlotsWith(ctx, events, tasks, schedule, link, local.Year(), local.Month(), now) + if err != nil { + return false, err + } + for _, slot := range slots { + if slot.Equal(start) { + return true, nil + } + } + return false, nil +} + +// deriveSlotsWith is DeriveSlotsForLinkAndMonth's and SlotAvailable's shared +// body, taking the Event/Task repositories explicitly rather than reading +// s.eventRepo/s.taskRepo directly — the seam that lets SlotAvailable hand it +// tx-bound repositories instead of BookingLinkService's own pooled ones. +func (s *BookingLinkService) deriveSlotsWith(ctx context.Context, events *repository.EventRepository, tasks *repository.TaskRepository, schedule repository.AvailabilitySchedule, link repository.BookingLink, year int, month time.Month, now time.Time) ([]time.Time, error) { + if month < 1 || month > 12 { + return nil, ErrInvalidMonth + } + + loc, err := time.LoadLocation(schedule.Tzid) + if err != nil { + return nil, fmt.Errorf("load location %q: %w", schedule.Tzid, err) + } + + ranges := make([]availability.Range, len(schedule.Ranges)) + for i, r := range schedule.Ranges { + ranges[i] = availability.Range{Weekday: r.Weekday, StartMinute: r.StartMinute, EndMinute: r.EndMinute} + } + + // The same calendar-aware step DeriveSlots itself takes for the + // horizon cutoff, computed here too so the Conflict set's Events are + // queried over the identical window derivation will actually consider + // — querying any narrower window could silently miss a Busy Occurrence + // derivation would otherwise have found. + horizonCutoff := now.AddDate(0, 0, link.BookingHorizonDays) + busy, err := s.buildBusyIntervals(ctx, events, tasks, link, now, horizonCutoff) + if err != nil { + return nil, err + } + + slots, err := availability.DeriveSlots(availability.Params{ + Ranges: ranges, + Tzid: schedule.Tzid, + Busy: busy, + DurationMinutes: link.DurationMinutes, + MinimumNoticeMinutes: link.MinimumNoticeMinutes, + BookingHorizonDays: link.BookingHorizonDays, + Now: now, + }) + if err != nil { + return nil, err + } + + filtered := make([]time.Time, 0, len(slots)) + for _, slot := range slots { + local := slot.In(loc) + if local.Year() == year && local.Month() == month { + filtered = append(filtered, slot) + } + } + return filtered, nil +} + +// buildBusyIntervals assembles a Booking Link's Conflict set into +// availability.BusyInterval values over [from, to) (ADR-0087): Busy +// Occurrences from its Calendars, expanded with the same Go recurrence +// expander the reminder scheduler already uses, plus the host's incomplete +// Tasks' Time blocks when the Tasks row is set. A Deadline plays no part at +// all here, and a completed Task is already excluded by ListIncomplete +// itself — neither ever closes a slot. All-day Events need no special case: +// their Busy is read the same way a timed Event's is, and an all-day Event +// simply defaults to Free (ADR-0086), so only an explicitly Busy one ever +// contributes. Takes events/tasks explicitly — deriveSlotsWith's own reason +// for taking them, one layer up. +func (s *BookingLinkService) buildBusyIntervals(ctx context.Context, events *repository.EventRepository, tasks *repository.TaskRepository, link repository.BookingLink, from, to time.Time) ([]availability.BusyInterval, error) { + rows, err := events.ListByCalendarIDs(ctx, link.ConflictCalendarIDs, &from, &to) + if err != nil { + return nil, fmt.Errorf("list conflict set events: %w", err) + } + + overridesByParent := make(map[string][]repository.Event) + var masters []repository.Event + for _, e := range rows { + if e.ParentID != nil { + overridesByParent[*e.ParentID] = append(overridesByParent[*e.ParentID], e) + continue + } + masters = append(masters, e) + } + + var busy []availability.BusyInterval + for _, master := range masters { + occurrences, err := expandBusyOccurrences(master, overridesByParent[master.ID], from, to) + if err != nil { + return nil, err + } + busy = append(busy, occurrences...) + } + + if link.TasksInConflictSet { + incomplete, err := tasks.ListIncomplete(ctx, link.UserID, link.WorkspaceID) + if err != nil { + return nil, fmt.Errorf("list incomplete tasks: %w", err) + } + for _, task := range incomplete { + if task.Start == nil || task.DurationMinutes == nil { + continue + } + busy = append(busy, availability.BusyInterval{ + Start: *task.Start, + End: task.Start.Add(time.Duration(*task.DurationMinutes) * time.Minute), + }) + } + } + + return busy, nil +} + +// expandBusyOccurrences returns master's own Occurrences overlapping +// [from, to) that are Busy, each replaced by its own Override when one +// exists for that Occurrence (an Override carries its own Busy +// independently of its parent's, ADR-0086) — mirroring +// event_series_read.go's own findOverrideForOccurrence rather than a +// second implementation of "does this Occurrence have an Override". +func expandBusyOccurrences(master repository.Event, overrides []repository.Event, from, to time.Time) ([]availability.BusyInterval, error) { + masterDuration := master.End.Sub(master.Start) + + // recurrence.ExpandOccurrences reports an Occurrence only when its own + // *start* falls in the window handed to it — correct for its other + // callers (CalDAV's time-range filter cares about the same thing), but + // an Occurrence already in progress at derivation time (started before + // from, still running) must still close a slot here. Widening the + // expansion window's lower bound by this Master's own duration, then + // filtering the result by genuine [start, end) overlap with the true + // [from, to) below, catches it without changing what "in the window" + // means for anyone else. + starts, err := recurrence.ExpandOccurrences(master.Rrule, master.Tzid, master.Start, from.Add(-masterDuration), to) + if err != nil { + return nil, fmt.Errorf("expand occurrences for event %s: %w", master.ID, err) + } + + var busy []availability.BusyInterval + for _, start := range starts { + if isExdate(master.Exdates, start) { + continue + } + + busyStart, busyEnd, isBusy := start, start.Add(masterDuration), master.Busy + if override, ok := findOverrideForOccurrence(overrides, start); ok { + busyStart, busyEnd, isBusy = override.Start, override.End, override.Busy + } + if !isBusy { + continue + } + // The widened lower bound above can surface an Occurrence that + // finished before the true window even starts; recurrence. + // ExpandOccurrences' own to-side exclusivity still guards the + // far edge, so only this near-edge check is needed here. + if !busyEnd.After(from) { + continue + } + busy = append(busy, availability.BusyInterval{Start: busyStart, End: busyEnd}) + } + return busy, nil +} + +func isExdate(exdates []time.Time, start time.Time) bool { + for _, exdate := range exdates { + if exdate.Equal(start) { + return true + } + } + return false +} diff --git a/server/internal/service/booking_link_slots_test.go b/server/internal/service/booking_link_slots_test.go new file mode 100644 index 0000000..365019b --- /dev/null +++ b/server/internal/service/booking_link_slots_test.go @@ -0,0 +1,103 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/XiovV/calich/server/internal/repository" +) + +// setUpBookingLinkSlotsFixture bootstraps a User with their default +// Workspace, a book-into Calendar, a Mon-Fri 09:00-17:00 Schedule in +// Etc/UTC, and a 60-minute Booking Link with no minimum notice and a +// generous horizon — TestDeriveSlotsForMonth_InProgressEvent builds on it +// directly, bypassing the HTTP handler (which always derives against the +// real wall clock) so the test can supply its own "now". +func setUpBookingLinkSlotsFixture(t *testing.T) (g *Graph, userID, workspaceID, linkID int64, calendarID string) { + t.Helper() + ctx := context.Background() + + g = newTestGraph(t) + user, _, err := g.Auth.Bootstrap(ctx) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + workspaces, err := g.Workspaces.ListForUser(ctx, user.ID) + if err != nil || len(workspaces) != 1 { + t.Fatalf("list workspaces: %v (%d)", err, len(workspaces)) + } + workspaceID = workspaces[0].ID + + calendarID = "11111111-1111-1111-1111-111111111111" + if _, err := g.Calendars.Create(ctx, user.ID, workspaceID, calendarID, CalendarWrite{Name: "Work", Color: "#12809CFF"}); err != nil { + t.Fatalf("create calendar: %v", err) + } + + ranges := make([]repository.AvailabilityRange, 5) + for i, weekday := range []int{1, 2, 3, 4, 5} { + ranges[i] = repository.AvailabilityRange{Weekday: weekday, StartMinute: 9 * 60, EndMinute: 17 * 60} + } + schedule, err := g.AvailabilitySchedules.Create(ctx, user.ID, "Default", "Etc/UTC", ranges) + if err != nil { + t.Fatalf("create schedule: %v", err) + } + + link, err := g.BookingLinks.Create(ctx, user.ID, workspaceID, BookingLinkWrite{ + Title: "Intro call", Slug: "intro-call", DurationMinutes: 60, Visibility: "public", + AvailabilityScheduleID: schedule.ID, BookIntoCalendarID: calendarID, + MinimumNoticeMinutes: 0, BookingHorizonDays: 30, TasksInConflictSet: true, + }) + if err != nil { + t.Fatalf("create booking link: %v", err) + } + + return g, user.ID, workspaceID, link.ID, calendarID +} + +// TestDeriveSlotsForMonth_InProgressEventClosesItsSlot covers an event +// already running at derivation time — started before "now", still Busy +// through part of its own scheduled slot — which must still close that +// slot. recurrence.ExpandOccurrences (and rrule-go's own Between) report an +// Occurrence only when its *start* falls in the window handed to them, so +// naively passing `now` as that lower bound silently drops an in-progress +// Event; expandBusyOccurrences widens it by the Event's own duration to +// compensate. +func TestDeriveSlotsForMonth_InProgressEventClosesItsSlot(t *testing.T) { + g, userID, workspaceID, linkID, calendarID := setUpBookingLinkSlotsFixture(t) + ctx := context.Background() + + monday := nextWeekdayUTC(time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), time.Monday) + eventStart := monday.Add(10 * time.Hour) + eventEnd := monday.Add(12 * time.Hour) // a 2-hour Busy event + // now sits mid-Occurrence — the event started at 10:00 and is still + // running — but the *candidate slot* this test checks (11:00-12:00) is + // still ahead of now, so minimum notice alone can't be what excludes + // it: only the Busy interval can. + now := eventStart.Add(30 * time.Minute) + laterSlot := monday.Add(11 * time.Hour) + + if _, err := g.Events.Create(ctx, userID, "evt-in-progress", EventWrite{ + CalendarID: calendarID, Title: "Already running", Busy: true, + Start: eventStart, End: eventEnd, + }); err != nil { + t.Fatalf("create in-progress event: %v", err) + } + + slots, err := g.BookingLinks.DeriveSlotsForMonth(ctx, userID, workspaceID, linkID, monday.Year(), monday.Month(), now) + if err != nil { + t.Fatalf("derive slots: %v", err) + } + for _, s := range slots { + if s.Equal(laterSlot) { + t.Fatalf("expected the in-progress event's remaining 11:00 slot to stay closed, got %v", slots) + } + } +} + +func nextWeekdayUTC(from time.Time, weekday time.Weekday) time.Time { + for from.Weekday() != weekday { + from = from.AddDate(0, 0, 1) + } + return from +} diff --git a/server/internal/service/booking_link_validation.go b/server/internal/service/booking_link_validation.go new file mode 100644 index 0000000..1e40b49 --- /dev/null +++ b/server/internal/service/booking_link_validation.go @@ -0,0 +1,91 @@ +package service + +import ( + "regexp" + "strconv" + "strings" + "unicode/utf8" +) + +const ( + // minDurationMinutes and maxDurationMinutes bound a Booking Link's + // Duration (#322): at least a minute, at most a day — long enough for + // any Custom free-entry value a User would actually type. + minDurationMinutes = 1 + maxDurationMinutes = 24 * 60 + + // maxSlugLength bounds validateSlug (#322, ADR-0084) — a Slug is the + // second half of the same URL path segment a Handle's first half + // occupies (`/:handle/:slug`), so it shares Handle's own length ceiling + // (auth_validation.go's maxHandleLength) without depending on that + // constant directly: the two are free to diverge later without one + // silently pulling the other along. + maxSlugLength = 39 +) + +// slugPattern is a Slug's own character set — identical to a Handle's +// (auth_validation.go's handlePattern): lowercase letters, digits, and +// single hyphens between them, since a Slug is just as much a raw URL path +// segment as a Handle is, with no encoding step between what a User types +// and what appears in the address bar. +var slugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +// validVisibilities are the three values a Booking Link's Visibility may +// take (ADR-0087, CONTEXT.md's Visibility entry). +var validVisibilities = map[string]bool{ + "public": true, + "private": true, + "paused": true, +} + +// normalizeSlug trims and lowercases a Slug the same way normalizeHandle +// folds a Handle. +func normalizeSlug(slug string) string { + return strings.ToLower(strings.TrimSpace(slug)) +} + +// validateSlug normalizes slug and checks it against slugPattern and +// maxSlugLength (#322, ADR-0084). +func validateSlug(slug string) (string, error) { + slug = normalizeSlug(slug) + if slug == "" { + return "", ErrInvalidSlug + } + if utf8.RuneCountInString(slug) > maxSlugLength { + return "", ErrInvalidSlug + } + if !slugPattern.MatchString(slug) { + return "", ErrInvalidSlug + } + return slug, nil +} + +// truncateSlug clamps s to maxSlugLength runes, trimming a trailing hyphen +// the cut can leave behind — BookingLinkService.Duplicate's own guard +// (mirroring auth_validation.go's appendHandleSuffix fix) against +// "<slug>-copy" silently exceeding the limit before any numeric +// disambiguation is even appended. +func truncateSlug(s string) string { + if utf8.RuneCountInString(s) <= maxSlugLength { + return s + } + runes := []rune(s) + return strings.TrimSuffix(string(runes[:maxSlugLength]), "-") +} + +// appendSlugSuffix appends "-<suffix>" to base for +// BookingLinkService.freeSlug's disambiguation loop, truncating base first +// to leave exact room for the marker — the same fix SuggestHandle's own +// numeric-suffix loop needed (auth_validation.go's appendHandleSuffix), +// applied here so a base already at maxSlugLength can't produce an +// over-length candidate or loop forever truncating the suffix straight +// back off again. +func appendSlugSuffix(base string, suffix int) string { + marker := "-" + strconv.Itoa(suffix) + maxBaseLen := maxSlugLength - len(marker) + if utf8.RuneCountInString(base) > maxBaseLen { + runes := []rune(base) + base = strings.TrimSuffix(string(runes[:maxBaseLen]), "-") + } + return base + marker +} diff --git a/server/internal/service/calendar.go b/server/internal/service/calendar.go index 30802d4..e012d2e 100644 --- a/server/internal/service/calendar.go +++ b/server/internal/service/calendar.go @@ -60,6 +60,9 @@ var ( // instead lands as their own colour override (ADR-0038), never the // Calendar's own stored colour. ErrCalendarFieldForbidden = errors.New("only the calendar's owner may change its name, subscription url, or feed alarms setting") + // ErrCalendarReferenced mirrors repository.ErrCalendarReferenced so + // handlers only import the service package's sentinels (#322, ADR-0087). + ErrCalendarReferenced = repository.ErrCalendarReferenced ) type CalendarService struct { @@ -78,13 +81,17 @@ type CalendarService struct { defaultReminders *repository.CalendarDefaultReminderRepository explicitReminders *repository.EventReminderExplicitRepository colorOverrides *repository.CalendarUserColorRepository - workspaces *repository.WorkspaceRepository - groupShares *repository.CalendarGroupShareRepository - groups *repository.GroupRepository + // exposures is Exposure's storage (ADR-0080): each User's own override of + // whether a Calendar appears in their own CalDAV home-set, keyed and + // shaped exactly like colorOverrides above. + exposures *repository.CalendarExposureRepository + workspaces *repository.WorkspaceRepository + groupShares *repository.CalendarGroupShareRepository + groups *repository.GroupRepository } -func NewCalendarService(db *sql.DB, calendars *repository.CalendarRepository, sources *repository.SourceRepository, shares *repository.CalendarShareRepository, users *repository.UserRepository, eventReminders *repository.EventReminderRepository, defaultReminders *repository.CalendarDefaultReminderRepository, explicitReminders *repository.EventReminderExplicitRepository, colorOverrides *repository.CalendarUserColorRepository, workspaces *repository.WorkspaceRepository, groupShares *repository.CalendarGroupShareRepository, groups *repository.GroupRepository) *CalendarService { - return &CalendarService{db: db, calendars: calendars, sources: sources, shares: shares, users: users, eventReminders: eventReminders, defaultReminders: defaultReminders, explicitReminders: explicitReminders, colorOverrides: colorOverrides, workspaces: workspaces, groupShares: groupShares, groups: groups} +func NewCalendarService(db *sql.DB, calendars *repository.CalendarRepository, sources *repository.SourceRepository, shares *repository.CalendarShareRepository, users *repository.UserRepository, eventReminders *repository.EventReminderRepository, defaultReminders *repository.CalendarDefaultReminderRepository, explicitReminders *repository.EventReminderExplicitRepository, colorOverrides *repository.CalendarUserColorRepository, exposures *repository.CalendarExposureRepository, workspaces *repository.WorkspaceRepository, groupShares *repository.CalendarGroupShareRepository, groups *repository.GroupRepository) *CalendarService { + return &CalendarService{db: db, calendars: calendars, sources: sources, shares: shares, users: users, eventReminders: eventReminders, defaultReminders: defaultReminders, explicitReminders: explicitReminders, colorOverrides: colorOverrides, exposures: exposures, workspaces: workspaces, groupShares: groupShares, groups: groups} } // attachSource populates c.Source from the calendar_sources table (#284, @@ -252,6 +259,11 @@ type CalendarWithAccess struct { // ShareCount is how many Shares the Calendar carries — what tells a // caller whether more than one person would be notified (#111). ShareCount int + // Exposed is the caller's own resolved Exposure answer (ADR-0080): their + // own override on this Calendar if they've set one, otherwise + // ResolveExposure's default. Read-only over the REST API today — only + // the CalDAV home-set filter and direct-path lookup act on it. + Exposed bool } // ListAccessible returns every Calendar userID has any Access to — owned @@ -363,7 +375,11 @@ func (s *CalendarService) toCalendarWithAccess(ctx context.Context, userID int64 if err != nil { return CalendarWithAccess{}, err } - return CalendarWithAccess{Calendar: c, Access: ResolveAccess(userID, c, role), Color: color, IsOwner: isOwner, OwnerName: ownerName, ShareCount: shareCount}, nil + exposed, err := s.ResolveExposure(ctx, userID, c, isOwner) + if err != nil { + return CalendarWithAccess{}, err + } + return CalendarWithAccess{Calendar: c, Access: ResolveAccess(userID, c, role), Color: color, IsOwner: isOwner, OwnerName: ownerName, ShareCount: shareCount, Exposed: exposed}, nil } // resolveDisplayColor computes DisplayColor(user, calendar) (ADR-0038, @@ -435,6 +451,96 @@ func (s *CalendarService) usedDisplayColors(ctx context.Context, userID, workspa return used, nil } +// defaultExposure computes Exposure's *default* answer (ADR-0080, +// CONTEXT.md's Exposure entry) for calendar, used only when the asking User +// has never set their own override: every Calendar defaults to exposed, +// except a Linked Calendar's own Owner, who defaults to unexposed — the +// connecting User almost certainly already syncs it natively at the +// Provider (ADR-0074's reasoning, carried forward as a default rather than +// an unconditional rule). isOwner must be unclamped ownership +// (calendar.UserID == userID), never Access.IsOwner(), mirroring +// resolveDisplayColor's own isOwner-vs-clamped-Access distinction. +func defaultExposure(isOwner bool, calendar repository.Calendar) bool { + return !(isOwner && isConnectionSource(calendar)) +} + +// ResolveExposure resolves userID's own Exposure answer for calendar +// (ADR-0080): their own override row if they've set one, otherwise +// defaultExposure's answer. Exported for a handler that already holds an +// Access-checked Calendar and its ownership (Create, Update, Subscribe, +// ImportConnectionCalendars) and just needs Exposure's value for the +// response, without AccessWithColor's heavier full resolution. +func (s *CalendarService) ResolveExposure(ctx context.Context, userID int64, calendar repository.Calendar, isOwner bool) (bool, error) { + override, err := s.exposures.Get(ctx, userID, calendar.ID) + if err == nil { + return override, nil + } + if !errors.Is(err, repository.ErrNotFound) { + return false, fmt.Errorf("get calendar exposure: %w", err) + } + return defaultExposure(isOwner, calendar), nil +} + +// SetExposure sets userID's own Exposure choice on id (ADR-0080) — open to +// any User with Access, Owner and accessor alike, unlike ADR-0038's colour +// override: Exposure has no "the Calendar's own" value for an Owner's write +// to land on instead, every principal answers only for themselves. Gated on +// requireRead, the same bar SetColorOverride uses. +func (s *CalendarService) SetExposure(ctx context.Context, userID int64, id string, exposed bool) error { + if err := s.requireRead(ctx, userID, id); err != nil { + return err + } + if err := s.exposures.Upsert(ctx, userID, id, exposed); err != nil { + return fmt.Errorf("upsert calendar exposure: %w", err) + } + return nil +} + +// AccessWithExposure resolves userID's Access to id together with their own +// resolved Exposure answer (ADR-0080) — CalDAV's direct-path GetCalendar, +// which must 404 identically whether id doesn't exist, the caller has no +// Access, or Access says yes but Exposure says no, mirroring the home-set +// listing's own condition (CONTEXT.md's Linked Calendar entry). Deliberately +// lean, unlike AccessWithColor: CalDAV has no use for the display colour or +// ownership metadata that resolving would also touch. Skips the Exposure +// lookup entirely when Access already says no, rather than spending a query +// resolving a value the caller is about to discard. +func (s *CalendarService) AccessWithExposure(ctx context.Context, userID int64, id string) (Access, repository.Calendar, bool, error) { + access, calendar, err := s.Access(ctx, userID, id) + if err != nil { + return AccessNone, repository.Calendar{}, false, err + } + if !access.CanRead() { + return access, calendar, false, nil + } + isOwner := calendar.UserID == userID + exposed, err := s.ResolveExposure(ctx, userID, calendar, isOwner) + if err != nil { + return AccessNone, repository.Calendar{}, false, err + } + return access, calendar, exposed, nil +} + +// RequireExposedAccess refuses id as repository.ErrNotFound unless userID +// can read it and it resolves as exposed to them (ADR-0080) — the one check +// every CalDAV entry point that resolves a Calendar straight off the +// request path must apply: calendar-query and calendar-multiget (query.go), +// sync-collection (sync.go), and PROPPATCH (proppatch.go), alongside +// GetCalendar's own use of AccessWithExposure directly. Without this, a +// client holding a cached or guessed collection path could keep reading (or +// renaming) a Calendar through one of those paths after Exposure said no, +// even though PROPFIND home-set discovery already hides it. +func (s *CalendarService) RequireExposedAccess(ctx context.Context, userID int64, id string) error { + access, _, exposed, err := s.AccessWithExposure(ctx, userID, id) + if err != nil { + return err + } + if !access.CanRead() || !exposed { + return repository.ErrNotFound + } + return nil +} + // AccessWithColor resolves userID's Access to id's Calendar together with // their resolved display colour (ADR-0038) — the REST single-Calendar fetch // path, which must show the caller's own colour rather than always the @@ -452,7 +558,11 @@ func (s *CalendarService) AccessWithColor(ctx context.Context, userID int64, id if err != nil { return CalendarWithAccess{}, err } - return CalendarWithAccess{Calendar: calendar, Access: access, Color: color, IsOwner: isOwner, OwnerName: ownerName, ShareCount: shareCount}, nil + exposed, err := s.ResolveExposure(ctx, userID, calendar, isOwner) + if err != nil { + return CalendarWithAccess{}, err + } + return CalendarWithAccess{Calendar: calendar, Access: access, Color: color, IsOwner: isOwner, OwnerName: ownerName, ShareCount: shareCount, Exposed: exposed}, nil } // OwnershipMeta resolves calendar's un-clamped ownership answer for userID @@ -784,10 +894,10 @@ func (s *CalendarService) Share(ctx context.Context, ownerID int64, calendarID, // RevokeShare removes targetUserID's Share on calendarID. Only calendarID's // Owner may call this. targetUserID's own Reminders on calendarID's Events, // their Default reminders and explicit-opt-out markers on calendarID itself, -// and their colour override, are all cleared with it — a Reminder, default, -// or colour with no Access behind it would otherwise linger, invisible, -// until targetUserID was ever shared with it again (ADR-0064's and -// ADR-0038's acceptance criteria). +// and their colour and Exposure overrides, are all cleared with it — a +// Reminder, default, colour, or Exposure choice with no Access behind it +// would otherwise linger, invisible, until targetUserID was ever shared with +// it again (ADR-0064's, ADR-0038's, and ADR-0080's acceptance criteria). func (s *CalendarService) RevokeShare(ctx context.Context, ownerID int64, calendarID string, targetUserID int64) error { if _, err := s.requireOwner(ctx, ownerID, calendarID); err != nil { return err @@ -905,8 +1015,8 @@ func (s *CalendarService) ShareTargets(ctx context.Context, ownerID int64, calen // repository.ErrNotFound if userID holds no Share on calendarID (including // when userID is the Owner, who never has one). userID's own Reminders on // calendarID's Events, their Default reminders and explicit-opt-out markers -// on calendarID itself, and their colour override, are all cleared with it, -// mirroring RevokeShare (ADR-0064, ADR-0038). +// on calendarID itself, and their colour and Exposure overrides, are all +// cleared with it, mirroring RevokeShare (ADR-0064, ADR-0038, ADR-0080). func (s *CalendarService) LeaveShare(ctx context.Context, userID int64, calendarID string) error { return s.withShareRevocationTx(ctx, func(repos shareRevocationRepos) error { if err := repos.shares.Delete(ctx, calendarID, userID); err != nil { @@ -930,16 +1040,18 @@ type shareRevocationRepos struct { defaultReminders *repository.CalendarDefaultReminderRepository explicitReminders *repository.EventReminderExplicitRepository colorOverrides *repository.CalendarUserColorRepository + exposures *repository.CalendarExposureRepository } // withShareRevocationTx runs fn inside a transaction, passing it // transaction-bound clones of the Share, EventReminder, -// CalendarDefaultReminder, EventReminderExplicit, and CalendarUserColor -// repositories, so RevokeShare's and LeaveShare's five writes — the Share -// delete and clearUserCalendarState's four — commit or roll back atomically: -// a crash or error partway through must never leave a User's Reminders or -// colour override lingering for a Calendar they no longer have Access to -// (#259, ADR-0018). Reads and validation belong outside fn, before +// CalendarDefaultReminder, EventReminderExplicit, CalendarUserColor, and +// CalendarExposure repositories, so RevokeShare's and LeaveShare's six +// writes — the Share delete and clearUserCalendarState's five — commit or +// roll back atomically: a crash or error partway through must never leave a +// User's Reminders, colour override, or Exposure choice lingering for a +// Calendar they no longer have Access to (#259, ADR-0018). Reads and +// validation belong outside fn, before // withShareRevocationTx is called (ADR-0018) — RevokeShare's requireOwner // check runs against the pooled repos beforehand, exactly as EventService's // withTx expects of its own callers. @@ -951,17 +1063,18 @@ func (s *CalendarService) withShareRevocationTx(ctx context.Context, fn func(rep defaultReminders: s.defaultReminders.WithTx(tx), explicitReminders: s.explicitReminders.WithTx(tx), colorOverrides: s.colorOverrides.WithTx(tx), + exposures: s.exposures.WithTx(tx), }) }) } // clearUserCalendarState clears every per-User-per-Calendar row a Share's // end leaves behind — Reminders on the Calendar's Events, Default reminders -// and explicit-opt-out markers on the Calendar itself, and the colour -// override — RevokeShare's and LeaveShare's shared tail (ADR-0064, -// ADR-0038): a Reminder, default, or colour with no Access behind it would -// otherwise linger, invisible, until userID was ever shared with calendarID -// again. +// and explicit-opt-out markers on the Calendar itself, and the colour and +// Exposure overrides — RevokeShare's and LeaveShare's shared tail (ADR-0064, +// ADR-0038, ADR-0080): a Reminder, default, colour, or Exposure choice with +// no Access behind it would otherwise linger, invisible, until userID was +// ever shared with calendarID again. func clearUserCalendarState(ctx context.Context, repos shareRevocationRepos, userID int64, calendarID string) error { if err := repos.eventReminders.DeleteByUserAndCalendar(ctx, userID, calendarID); err != nil { return fmt.Errorf("clear reminders: %w", err) @@ -975,6 +1088,9 @@ func clearUserCalendarState(ctx context.Context, repos shareRevocationRepos, use if err := repos.colorOverrides.Delete(ctx, userID, calendarID); err != nil { return fmt.Errorf("clear calendar color override: %w", err) } + if err := repos.exposures.Delete(ctx, userID, calendarID); err != nil { + return fmt.Errorf("clear calendar exposure override: %w", err) + } return nil } diff --git a/server/internal/service/calendar_set.go b/server/internal/service/calendar_set.go new file mode 100644 index 0000000..926b258 --- /dev/null +++ b/server/internal/service/calendar_set.go @@ -0,0 +1,148 @@ +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 + calendars *CalendarService +} + +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 +// 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, +// 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) + } + + 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, 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 CalendarSetWithMembers{}, ErrInvalidCalendarSetName + } + + renamed, err := s.sets.Rename(ctx, id, userID, workspaceID, name) + if err != nil { + 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 CalendarSetWithMembers{CalendarSet: renamed, CalendarIDs: ids}, 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 +} + +// 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/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 aaee7b3..d99e3ab 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 } @@ -237,7 +307,7 @@ func (s *ConnectionService) sendWriteBackUpsert(ctx context.Context, msg reposit // instance at Google (CANCEL_INSTANCE), and folding the same cancellations // in here as EXDATE lines would double-represent them until the series // drifts. buildGooglePatch's recurrence is the bare RRULE. - patch := buildGooglePatch(event.Title, event.Start, event.End, event.AllDay, event.Tzid, event.Rrule, event.Description, event.Location, event.URL) + patch := buildGooglePatch(event.Title, event.Start, event.End, event.AllDay, event.Tzid, event.Rrule, event.Description, event.Location, event.URL, event.Busy) accessToken := wc.accessToken() if isCreate { @@ -305,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 } } @@ -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 @@ -417,7 +487,7 @@ func (s *ConnectionService) sendWriteBackInstance(ctx context.Context, msg repos instanceID := instance.ID etag := googleEtag(instance.ETag) - patch := buildGooglePatch(override.Title, override.Start, override.End, override.AllDay, override.Tzid, "", override.Description, override.Location, override.URL) + patch := buildGooglePatch(override.Title, override.Start, override.End, override.AllDay, override.Tzid, "", override.Description, override.Location, override.URL, override.Busy) var updated googleEventJSON for attempt := 1; ; attempt++ { @@ -446,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 } } @@ -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 @@ -556,28 +627,31 @@ 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 // 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) @@ -585,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 d26eaba..326f5a8 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" ) @@ -31,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), @@ -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)) @@ -376,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 @@ -570,3 +601,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.go b/server/internal/service/event.go index 0bd7e45..73a8353 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 @@ -221,7 +230,7 @@ func classifyUpdate(existing repository.Event, write EventWrite) updateEffects { if material { newSequence++ } - contentChanged := material || existing.Title != write.Title || existing.Description != write.Description || existing.Location != write.Location || existing.URL != write.URL || !colorEqual(existing.Color, write.Color) + contentChanged := material || existing.Title != write.Title || existing.Description != write.Description || existing.Location != write.Location || existing.URL != write.URL || !colorEqual(existing.Color, write.Color) || existing.Busy != write.Busy return updateEffects{ discardChildren: discardChildren, @@ -453,7 +462,20 @@ type txRepos struct { // commits or rolls back atomically. Reads and validation belong outside fn, // before withTx is called (ADR-0018). func (s *EventService) withTx(ctx context.Context, fn func(repos txRepos) error) error { + return s.withTxChecked(ctx, nil, fn) +} + +// withTxChecked is withTx's own body, widened with an optional preCheck that +// runs first, inside the transaction, before fn (and before repos are even +// built) — Create's EventWrite.PreCommitCheck's seam. withTx above is just +// this with preCheck nil, which every caller but Create is. +func (s *EventService) withTxChecked(ctx context.Context, preCheck func(ctx context.Context, tx *sql.Tx) error, fn func(repos txRepos) error) error { return repository.WithTx(ctx, s.db, func(tx *sql.Tx) error { + if preCheck != nil { + if err := preCheck(ctx, tx); err != nil { + return err + } + } repos := txRepos{ events: s.events.WithTx(tx), exceptions: s.exceptions.WithTx(tx), @@ -486,6 +508,12 @@ type EventWrite struct { Title string Start, End time.Time AllDay bool + // Busy mirrors repository.Event.Busy — iCalendar's own TRANSP (ADR-0086). + // A newly created timed Event is Busy, an all-day one Free; the caller + // (the frontend's Event modal, or a decoded import/CalDAV/Google write) + // always supplies a concrete value — this service never re-derives it + // from AllDay itself. + Busy bool Rrule string ParentID *string RecurrenceID *time.Time @@ -514,6 +542,17 @@ type EventWrite struct { AttendeeUserIDs []int64 AttendeeGroupIDs []int64 AttendeeEmails []string + // AllowAttendeesOnConnectionSource narrows Create's own "no Attendees on + // a Connection Source" guard (ADR-0052) for exactly one caller: a + // Booking Link's write (#326, ADR-0087), whose Book-into Calendar is a + // legal Book-into target even when it's a writable Linked Calendar. The + // visitor's Attendee row stays local-only, same as ADR-0052 already + // requires — nothing here mirrors it to the Provider — so it doesn't + // reopen the guard's own reasoning (a Linked Calendar's Events carry no + // Attendees this app pushes upstream); it only lets that local row and + // the Event's own ordinary Write-back coexist, which the guard's blanket + // refusal had no way to express. False (refused) on every other Create. + AllowAttendeesOnConnectionSource bool // CopyRemindersFrom, when set, copies every User's Reminder rows from // that Event onto the one Create mints, inside the same transaction // (ADR-0064): ParentID for a fresh Override (its Master's Reminder set), @@ -521,6 +560,14 @@ type EventWrite struct { // so a User whose Reminder applied to the old row's future Occurrences // doesn't silently lose it. Nil on every other Create. CopyRemindersFrom *string + // PreCommitCheck, when set, runs first inside Create's own write + // transaction — before the Event row is even inserted — and aborts the + // whole write (rolling back cleanly) if it returns an error. A Booking + // Link's public write (#326, ADR-0087) is the only caller: it re-derives + // its slot here, against tx-bound repositories it builds from the *sql.Tx + // itself, so two visitors racing the same slot can never both win — the + // loser's transaction simply never inserts. Nil on every other Create. + PreCommitCheck func(ctx context.Context, tx *sql.Tx) error } // fields projects the write onto the columns the repository stores, dropping @@ -532,6 +579,7 @@ func (w EventWrite) fields() repository.EventFields { Start: w.Start, End: w.End, AllDay: w.AllDay, + Busy: w.Busy, Rrule: w.Rrule, ParentID: w.ParentID, RecurrenceID: w.RecurrenceID, @@ -583,15 +631,17 @@ func (s *EventService) Create(ctx context.Context, userID int64, id string, writ // Creating an Event on a writable Linked Calendar is allowed (#292, // ADR-0077): a plain Master queues an events.insert (POST), an Override — // "This event" editing an as-yet-unmodified Occurrence (#293, ADR-0078) — - // queues an instance PATCH keyed on the Master's local id. Only an - // Attendee is still refused: a Linked Calendar's Events carry none this - // app mirrors to the Provider (ADR-0052). A read-only Connection Source is - // already refused above (requireWritableCalendar's Access clamp), same as - // a Subscription's. + // queues an instance PATCH keyed on the Master's local id. An Attendee is + // otherwise still refused: a Linked Calendar's Events carry none this app + // mirrors to the Provider (ADR-0052) — except a Booking Link's own write, + // which sets AllowAttendeesOnConnectionSource (#326, ADR-0087). A + // read-only Connection Source is already refused above + // (requireWritableCalendar's Access clamp), same as a Subscription's. enqueueCreateWriteBack := false enqueueInstanceWriteBack := false if isConnectionSource(calendar) { - if len(write.AttendeeUserIDs) > 0 || len(write.AttendeeGroupIDs) > 0 || len(write.AttendeeEmails) > 0 { + hasAttendees := len(write.AttendeeUserIDs) > 0 || len(write.AttendeeGroupIDs) > 0 || len(write.AttendeeEmails) > 0 + if hasAttendees && !write.AllowAttendeesOnConnectionSource { return repository.Event{}, ErrLinkedCalendarWriteUnsupported } if err := s.requireLiveConnection(ctx, calendar); err != nil { @@ -620,7 +670,7 @@ func (s *EventService) Create(ctx context.Context, userID int64, id string, writ workspaceID := calendar.WorkspaceID var event repository.Event - err = s.withTx(ctx, func(repos txRepos) error { + err = s.withTxChecked(ctx, write.PreCommitCheck, func(repos txRepos) error { seq, err := repos.sync.NextChangeSeq(ctx) if err != nil { return err @@ -908,22 +958,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_busy_test.go b/server/internal/service/event_busy_test.go new file mode 100644 index 0000000..c0b921b --- /dev/null +++ b/server/internal/service/event_busy_test.go @@ -0,0 +1,147 @@ +// event_busy_test.go covers #319/ADR-0086's Busy field: Create/Update +// persisting and round-tripping it, that it is settable independently on an +// Override from its Master, and that an unrelated field edit leaves it +// untouched. +package service + +import ( + "context" + "testing" + "time" +) + +func TestEventService_Create_PersistsBusy(t *testing.T) { + svc, userID, calendarID := newTestEventService(t) + ctx := context.Background() + start := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC) + end := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + + event, err := svc.Create(ctx, userID, "evt-1", EventWrite{ + CalendarID: calendarID, Title: "Standup", Start: start, End: end, Busy: true, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if !event.Busy { + t.Fatalf("expected a timed Event's create to persist Busy, got %+v", event) + } +} + +// TestEventService_Create_AllDayFree covers an all-day Event's own create +// path passing Busy false through unchanged — this service never re-derives +// Busy from AllDay itself (ADR-0086 leaves that departure to the caller). +func TestEventService_Create_AllDayFree(t *testing.T) { + svc, userID, calendarID := newTestEventService(t) + ctx := context.Background() + start := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 8, 5, 0, 0, 0, 0, time.UTC) + + event, err := svc.Create(ctx, userID, "evt-1", EventWrite{ + CalendarID: calendarID, Title: "Holiday", Start: start, End: end, AllDay: true, Busy: false, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if event.Busy { + t.Fatalf("expected an all-day create's own Busy:false to persist as Free, got %+v", event) + } +} + +func TestEventService_Update_TogglesBusy(t *testing.T) { + svc, userID, calendarID := newTestEventService(t) + ctx := context.Background() + start := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC) + end := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + + event, err := svc.Create(ctx, userID, "evt-1", EventWrite{ + CalendarID: calendarID, Title: "Standup", Start: start, End: end, Busy: true, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + updated, err := svc.Update(ctx, userID, event.ID, EventWrite{ + CalendarID: calendarID, Title: "Standup", Start: start, End: end, Busy: false, + }) + if err != nil { + t.Fatalf("update: %v", err) + } + if updated.Busy { + t.Fatalf("expected update to flip the Event to Free, got %+v", updated) + } + + fetched, err := svc.Get(ctx, userID, event.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if fetched.Busy { + t.Fatalf("expected Free to persist, got %+v", fetched) + } +} + +// TestEventService_CreateOverride_BusyIndependentOfMaster covers ADR-0086's +// "settable on a Master and independently on an Override" requirement — one +// Occurrence of a series can flip to Free without touching its Master or +// becoming anything other than an ordinary Override. +func TestEventService_CreateOverride_BusyIndependentOfMaster(t *testing.T) { + svc, userID, calendarID := newTestEventService(t) + ctx := context.Background() + start := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC) + end := time.Date(2026, 1, 1, 9, 30, 0, 0, time.UTC) + + master, err := svc.Create(ctx, userID, "master", EventWrite{ + CalendarID: calendarID, Title: "Standup", Start: start, End: end, Rrule: "FREQ=DAILY", Busy: true, + }) + if err != nil { + t.Fatalf("create master: %v", err) + } + + recurrenceID := time.Date(2026, 1, 2, 9, 0, 0, 0, time.UTC) + override, err := svc.Create(ctx, userID, "override", EventWrite{ + CalendarID: calendarID, Title: "Standup (optional today)", + Start: time.Date(2026, 1, 2, 9, 0, 0, 0, time.UTC), End: time.Date(2026, 1, 2, 9, 30, 0, 0, time.UTC), + ParentID: &master.ID, RecurrenceID: &recurrenceID, Busy: false, + }) + if err != nil { + t.Fatalf("create override: %v", err) + } + if override.Busy { + t.Fatalf("expected the Override to be Free, got %+v", override) + } + + refetchedMaster, err := svc.Get(ctx, userID, master.ID) + if err != nil { + t.Fatalf("get master: %v", err) + } + if !refetchedMaster.Busy { + t.Fatalf("expected the Master to remain Busy, unaffected by its Override, got %+v", refetchedMaster) + } +} + +// TestEventService_Update_UnrelatedFieldEditLeavesBusyAlone covers Busy +// surviving a plain edit of another field — Update always rewrites every +// column from write, so a caller must carry the row's current Busy forward, +// exactly as it must for title or color. +func TestEventService_Update_UnrelatedFieldEditLeavesBusyAlone(t *testing.T) { + svc, userID, calendarID := newTestEventService(t) + ctx := context.Background() + start := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC) + end := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + + event, err := svc.Create(ctx, userID, "evt-1", EventWrite{ + CalendarID: calendarID, Title: "Standup", Start: start, End: end, Busy: false, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + updated, err := svc.Update(ctx, userID, event.ID, EventWrite{ + CalendarID: calendarID, Title: "Standup (renamed)", Start: start, End: end, Busy: false, + }) + if err != nil { + t.Fatalf("update: %v", err) + } + if updated.Busy { + t.Fatalf("expected Free to survive an unrelated rename, got %+v", updated) + } +} 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_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/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 0655d7f..c21c2a3 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, 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) + 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) + return s.upsertSeries(ctx, repos, calendarID, masterID, userID, ownerID, seq, write, false, true, nil) } // tombstoneSubscribedSeries deletes masterID outright (cascading to its @@ -308,6 +308,13 @@ type SeriesWrite struct { Title, Description, Location, URL string Start, End time.Time AllDay bool + // Busy mirrors EventWrite.Busy — iCalendar's own TRANSP (ADR-0086), + // decoded verbatim from the incoming source (a CalDAV PUT, an ICS + // import, or the Google mapper) rather than re-derived from AllDay: the + // all-day-defaults-Free departure applies only to a brand-new Event this + // app itself creates, never to one whose own transparency a source + // already stated. + Busy bool Tzid *string Rrule string Reminders []repository.Reminder @@ -363,6 +370,9 @@ type OverrideWrite struct { Title, Description, Location, URL string Start, End time.Time AllDay bool + // Busy mirrors SeriesWrite.Busy, scoped to this Override alone (ADR-0086) + // — settable independently of its Master's, like Color. + Busy bool Tzid *string Reminders []repository.Reminder // ExternalUID mirrors SeriesWrite.ExternalUID — an Override shares its @@ -399,6 +409,7 @@ func (w SeriesWrite) fields(calendarID string) repository.EventFields { Start: w.Start, End: w.End, AllDay: w.AllDay, + Busy: w.Busy, Rrule: w.Rrule, Tzid: w.Tzid, Description: w.Description, @@ -428,6 +439,7 @@ func (o OverrideWrite) fields(calendarID, masterID string) repository.EventField Start: o.Start, End: o.End, AllDay: o.AllDay, + Busy: o.Busy, Tzid: o.Tzid, Description: o.Description, Location: o.Location, @@ -550,7 +562,29 @@ 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. +// 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 @@ -574,11 +608,43 @@ 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) } } + + // 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) } @@ -603,6 +669,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}) @@ -628,6 +710,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) } @@ -636,6 +726,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 } @@ -651,6 +757,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 { @@ -671,6 +791,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 @@ -680,16 +809,30 @@ func (s *EventService) PutSeries(ctx context.Context, userID int64, calendarID, if err != nil { return repository.Event{}, nil, err } - // ADR-0074 hides every Linked Calendar from CalDAV discovery - // unconditionally — caldavserver.GetCalendar 404s one directly, so no - // real client ever learns a path into one. This is the same refusal for - // a client that already holds (cached, or guessed) a path from before — - // repository.ErrNotFound, not ErrLinkedCalendarWriteUnsupported, so a - // stale PUT 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 (#290). + + 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) @@ -704,6 +847,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 { @@ -714,7 +864,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, wb) }) if err != nil { return repository.Event{}, nil, fmt.Errorf("put series: %w", err) @@ -722,3 +872,172 @@ 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, busy, tzid, +// rrule, description, location, url — busy added by ADR-0086) 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 || + existing.Busy != write.Busy || + !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 || + existing.Busy != write.Busy || + !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.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 8e9d371..4f61b05 100644 --- a/server/internal/service/event_writeback_test.go +++ b/server/internal/service/event_writeback_test.go @@ -357,17 +357,225 @@ func TestEventService_Update_CarriesForwardProviderOwnedFields(t *testing.T) { } } -// TestEventService_PutSeries_RefusesConnectionCalendarMatchingCalDAVsPosture -// covers the CalDAV write seam directly (#290): ADR-0074 hides every Linked -// Calendar from CalDAV discovery, and caldavserver.GetCalendar independently -// 404s one on a fresh PROPFIND — but PutCalendarObject itself resolves -// calendarID straight off the request path with no such re-check, 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_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, nil) + }) + 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, nil) + }) + 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_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/google.go b/server/internal/service/google.go index eb438e2..37410e7 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 { @@ -532,6 +557,9 @@ type googleEventJSON struct { Description string `json:"description"` Location string `json:"location"` ColorID string `json:"colorId"` + // Transparency is Google's own free/busy field (ADR-0086) — "opaque", + // "transparent", or empty (Google's own default, "opaque"). + Transparency string `json:"transparency"` Start googleEventDateTimeJSON `json:"start"` End googleEventDateTimeJSON `json:"end"` RecurringEventID string `json:"recurringEventId"` @@ -575,6 +603,7 @@ func toGoogleEvent(j googleEventJSON) googleEvent { Description: j.Description, Location: j.Location, ColorID: j.ColorID, + Transparency: j.Transparency, Start: toGoogleEventDateTime(j.Start), End: toGoogleEventDateTime(j.End), RecurringEventID: j.RecurringEventID, @@ -644,12 +673,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 { @@ -701,16 +730,16 @@ type googleEventPatchSourceJSON struct { // googleEventPatchBody is the wire shape of a Write-back PATCH request body // (#290, ADR-0075) — and the load-bearing type this ticket exists to build: // it has a field for exactly title, description, location, start, end, -// recurrence and Event URL, and no field for attendees, conferenceData, -// visibility, or anything else this app doesn't model. There is no -// constructor that takes a whole repository.Event and no method that lets a -// caller set a field this struct doesn't declare — buildGooglePatch is the -// only function that produces one, and it reads a fixed, named set of -// arguments, never an Event value it could forward wholesale. That is what -// makes "a whole-Event replace is impossible to express at the Provider -// seam" true by construction rather than by convention: there is no -// events.update call in this file, and this type could not serialize one if -// there were. +// recurrence, transparency (ADR-0086) and Event URL, and no field for +// attendees, conferenceData, visibility, or anything else this app doesn't +// model. There is no constructor that takes a whole repository.Event and no +// method that lets a caller set a field this struct doesn't declare — +// buildGooglePatch is the only function that produces one, and it reads a +// fixed, named set of arguments, never an Event value it could forward +// wholesale. That is what makes "a whole-Event replace is impossible to +// express at the Provider seam" true by construction rather than by +// convention: there is no events.update call in this file, and this type +// could not serialize one if there were. type googleEventPatchBody struct { // ID is set only on an events.insert (#292, ADR-0077) — a client-supplied // id derived from the local Event's id, so a retry after Google has @@ -720,13 +749,18 @@ 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"` + // Transparency is Google's own free/busy field (ADR-0086), always sent + // explicitly ("opaque" or "transparent") — no omitempty, since "opaque" + // (Busy) is the common case and must never be confused with "leave + // whatever's there alone". + Transparency string `json:"transparency"` // 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 +769,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 +835,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 +848,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 @@ -798,27 +890,37 @@ func googlePatchSource(eventTitle, eventURL string) *googleEventPatchSourceJSON return &googleEventPatchSourceJSON{Title: title, URL: eventURL} } +// googleTransparency renders busy as Google's own free/busy vocabulary +// (ADR-0086) — the outbound counterpart to googleBusy. +func googleTransparency(busy bool) string { + if busy { + return "opaque" + } + return "transparent" +} + // buildGooglePatch is the one function that produces a googleEventPatchBody // (#290, ADR-0075) — the field-scoped patch compiler ADR-0075 requires be // enforced at the Provider seam, not by convention at each call site. Its // argument list is exactly ADR-0075's allow-list (title, start, end, -// all-day, Anchor zone, recurrence rule, description, location, Event URL) and -// nothing a caller could use to smuggle an Attendee, conferenceData, or a -// visibility change through: those fields don't exist on this function's -// signature, so there is nothing to pass even by mistake. An empty rrule (a -// plain Master, or a recurring instance's own PATCH, which never carries a -// rule of its own) omits the recurrence key entirely. Cancelled Occurrences -// are never in scope here — they are `status: cancelled` instances, pushed by -// CANCEL_INSTANCE (#293, ADR-0078). -func buildGooglePatch(title string, start, end time.Time, allDay bool, tzid *string, rrule string, description, location, eventURL string) googleEventPatchBody { +// all-day, busy, Anchor zone, recurrence rule, description, location, Event +// URL — busy added by ADR-0086) and nothing a caller could use to smuggle an +// Attendee, conferenceData, or a visibility change through: those fields +// don't exist on this function's signature, so there is nothing to pass even +// by mistake. An empty rrule (a plain Master, or a recurring instance's own +// PATCH, which never carries a rule of its own) omits the recurrence key +// entirely. Cancelled Occurrences are never in scope here — they are +// `status: cancelled` instances, pushed by CANCEL_INSTANCE (#293, ADR-0078). +func buildGooglePatch(title string, start, end time.Time, allDay bool, tzid *string, rrule string, description, location, eventURL string, busy bool) googleEventPatchBody { return googleEventPatchBody{ - Summary: title, - Description: description, - Location: location, - Start: encodeGoogleEventDateTime(start, allDay, tzid), - End: encodeGoogleEventDateTime(end, allDay, tzid), - Recurrence: encodeGoogleRecurrence(rrule), - Source: googlePatchSource(title, eventURL), + Summary: title, + Description: description, + Location: location, + Start: encodeGoogleEventDateTime(start, allDay, tzid), + End: encodeGoogleEventDateTime(end, allDay, tzid), + Recurrence: encodeGoogleRecurrence(rrule), + Transparency: googleTransparency(busy), + Source: googlePatchSource(title, eventURL), } } @@ -863,8 +965,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 +995,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 +1044,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 +1075,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 +1140,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 +1203,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_mapper.go b/server/internal/service/google_mapper.go index ed5a2ac..f93fef8 100644 --- a/server/internal/service/google_mapper.go +++ b/server/internal/service/google_mapper.go @@ -57,6 +57,10 @@ type googleEvent struct { // event carries no colour. Mapped to a hex inbound (#289, ADR-0075) and // shadow-tracked; never sent back. ColorID string + // Transparency is Google's own free/busy field ("opaque" or + // "transparent", empty meaning Google's own default of "opaque") — the + // Provider's counterpart to iCalendar's TRANSP (ADR-0086). + Transparency string Start, End googleEventDateTime RecurringEventID string OriginalStartTime *googleEventDateTime @@ -297,6 +301,7 @@ func mapGoogleMaster(master googleEvent, instances []googleEvent) (SeriesWrite, Start: start, End: firstOf(decodeGoogleTime(master.End)), AllDay: allDay, + Busy: googleBusy(master.Transparency), Tzid: tzid, Rrule: rrule, Exdates: exdates, @@ -345,6 +350,7 @@ func mapGoogleInstances(instances []googleEvent, externalUID string) (overrides Start: iStart, End: firstOf(decodeGoogleTime(instance.End)), AllDay: iAllDay, + Busy: googleBusy(instance.Transparency), Tzid: iTzid, ExternalUID: externalUID, Color: iProviderColor, @@ -497,6 +503,14 @@ func parseGoogleExdateValue(params map[string]string, value string, fallbackTzid return out, nil } +// googleBusy decodes Google's own transparency field ("opaque"/"transparent", +// case-sensitive, empty meaning Google's own default of "opaque") into Busy +// (ADR-0086) — Free only when it is literally "transparent", mirroring +// icalendar.parseEventBusy's own TRANSP rule so the two never drift apart. +func googleBusy(transparency string) bool { + return transparency != "transparent" +} + // googleEtag strips the double-quote wrapping Google's own etag values // carry (e.g. `"\"abc123\""` decoded to `"abc123"`), storing the bare // validator this app's own If-Match usage will send back verbatim. Nil for diff --git a/server/internal/service/google_mapper_test.go b/server/internal/service/google_mapper_test.go index 446b8e4..7ae434f 100644 --- a/server/internal/service/google_mapper_test.go +++ b/server/internal/service/google_mapper_test.go @@ -452,6 +452,81 @@ func TestMapGoogleEvents_InstanceColorIDIsIndependentOfMaster(t *testing.T) { } } +// TestMapGoogleEvents_NoTransparencyDefaultsToBusy covers ADR-0086: an empty +// transparency field — Google's own default — maps to Busy, matching RFC +// 5545's OPAQUE default. +func TestMapGoogleEvents_NoTransparencyDefaultsToBusy(t *testing.T) { + events := []googleEvent{ + { + ID: "evt-1", + Summary: "Dentist", + Start: googleEventDateTime{DateTime: "2026-01-15T10:00:00-05:00", TimeZone: "America/New_York"}, + End: googleEventDateTime{DateTime: "2026-01-15T11:00:00-05:00", TimeZone: "America/New_York"}, + }, + } + + series, _ := mapGoogleEvents(events) + if !series[0].Write.Busy { + t.Fatalf("expected an absent transparency to map to Busy, got Free") + } +} + +// TestMapGoogleEvents_TransparentMapsToFree covers Google's "transparent" +// value mapping to Free (ADR-0086). +func TestMapGoogleEvents_TransparentMapsToFree(t *testing.T) { + events := []googleEvent{ + { + ID: "evt-1", + Summary: "Holiday", + Transparency: "transparent", + Start: googleEventDateTime{Date: "2026-01-15"}, + End: googleEventDateTime{Date: "2026-01-16"}, + }, + } + + series, _ := mapGoogleEvents(events) + if series[0].Write.Busy { + t.Fatalf("expected transparency \"transparent\" to map to Free, got Busy") + } +} + +// TestMapGoogleEvents_InstanceTransparencyIsIndependentOfMaster covers +// ADR-0086's per-instance Busy, mirroring +// TestMapGoogleEvents_InstanceColorIDIsIndependentOfMaster. +func TestMapGoogleEvents_InstanceTransparencyIsIndependentOfMaster(t *testing.T) { + events := []googleEvent{ + { + ID: "series-1", + Summary: "Standup", + Start: googleEventDateTime{DateTime: "2026-01-05T09:00:00-05:00", TimeZone: "America/New_York"}, + End: googleEventDateTime{DateTime: "2026-01-05T09:15:00-05:00", TimeZone: "America/New_York"}, + Recurrence: []string{"RRULE:FREQ=WEEKLY;BYDAY=MO"}, + }, + { + ID: "series-1_20260112T140000Z", + RecurringEventID: "series-1", + OriginalStartTime: &googleEventDateTime{DateTime: "2026-01-12T09:00:00-05:00", TimeZone: "America/New_York"}, + Transparency: "transparent", + Summary: "Standup (optional today)", + Start: googleEventDateTime{DateTime: "2026-01-12T09:00:00-05:00", TimeZone: "America/New_York"}, + End: googleEventDateTime{DateTime: "2026-01-12T09:15:00-05:00", TimeZone: "America/New_York"}, + Status: "confirmed", + }, + } + + series, _ := mapGoogleEvents(events) + write := series[0].Write + if !write.Busy { + t.Fatalf("expected the Master to stay Busy, got Free") + } + if len(write.Overrides) != 1 { + t.Fatalf("expected 1 override, got %d", len(write.Overrides)) + } + if write.Overrides[0].Busy { + t.Fatalf("expected the Override's own transparency, independent of the Master's, got Busy") + } +} + func TestParseGoogleExdateValue_MultipleCommaSeparatedValues(t *testing.T) { parsed, err := parseGoogleExdateValue(map[string]string{"TZID": "America/New_York"}, "20260209T090000,20260216T090000", nil) if err != nil { diff --git a/server/internal/service/google_writeback_test.go b/server/internal/service/google_writeback_test.go index ac696b7..5c8f2ad 100644 --- a/server/internal/service/google_writeback_test.go +++ b/server/internal/service/google_writeback_test.go @@ -110,7 +110,7 @@ func TestBuildGooglePatch_TitleOnlyChangeCarriesExactlyTheAllowedFields(t *testi start := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC) end := time.Date(2026, 1, 1, 9, 30, 0, 0, time.UTC) - patch := buildGooglePatch("Standup (renamed)", start, end, false, nil, "", "", "", "") + patch := buildGooglePatch("Standup (renamed)", start, end, false, nil, "", "", "", "", true) raw, err := json.Marshal(patch) if err != nil { @@ -121,7 +121,7 @@ func TestBuildGooglePatch_TitleOnlyChangeCarriesExactlyTheAllowedFields(t *testi t.Fatalf("unmarshal patch: %v", err) } - wantKeys := map[string]bool{"summary": true, "description": true, "location": true, "start": true, "end": true, "source": true} + wantKeys := map[string]bool{"summary": true, "description": true, "location": true, "start": true, "end": true, "transparency": true, "source": true} for key := range m { if !wantKeys[key] { t.Fatalf("patch carried unexpected key %q (full body: %s)", key, raw) @@ -134,8 +134,9 @@ func TestBuildGooglePatch_TitleOnlyChangeCarriesExactlyTheAllowedFields(t *testi // The load-bearing negative assertions: never present, whatever the // caller passed, because googleEventPatchBody has no field for any of // them at all (ADR-0075's "no guest list, no conference data, no - // visibility"). - for _, forbidden := range []string{"attendees", "conferenceData", "visibility", "guestsCanModify", "transparency", "extendedProperties"} { + // visibility"). transparency is no longer among them — ADR-0086 adds it + // to the allow-list above. + for _, forbidden := range []string{"attendees", "conferenceData", "visibility", "guestsCanModify", "extendedProperties"} { if _, ok := m[forbidden]; ok { t.Fatalf("patch must never carry %q — a whole-Event replace must be impossible to express here", forbidden) } @@ -151,7 +152,7 @@ func TestBuildGooglePatch_OmitsRecurrenceForANonRecurringEvent(t *testing.T) { start := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC) end := time.Date(2026, 1, 1, 9, 30, 0, 0, time.UTC) - patch := buildGooglePatch("Standup", start, end, false, nil, "", "", "", "") + patch := buildGooglePatch("Standup", start, end, false, nil, "", "", "", "", true) raw, _ := json.Marshal(patch) var m map[string]any _ = json.Unmarshal(raw, &m) @@ -171,7 +172,7 @@ func TestBuildGooglePatch_RecurrenceCarriesRruleAndNeverAnExdate(t *testing.T) { end := time.Date(2026, 1, 1, 9, 30, 0, 0, time.UTC) tzid := "America/New_York" - patch := buildGooglePatch("Standup", start, end, false, &tzid, "FREQ=WEEKLY", "", "", "") + patch := buildGooglePatch("Standup", start, end, false, &tzid, "FREQ=WEEKLY", "", "", "", true) if len(patch.Recurrence) != 1 { t.Fatalf("expected exactly the RRULE line, got %v", patch.Recurrence) @@ -194,7 +195,7 @@ func TestBuildGooglePatch_ClearedURLSendsExplicitNullSource(t *testing.T) { start := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC) end := time.Date(2026, 1, 1, 9, 30, 0, 0, time.UTC) - patch := buildGooglePatch("Standup", start, end, false, nil, "", "", "", "") + patch := buildGooglePatch("Standup", start, end, false, nil, "", "", "", "", true) raw, err := json.Marshal(patch) if err != nil { t.Fatalf("marshal patch: %v", err) @@ -221,8 +222,111 @@ func TestBuildGooglePatch_NonHTTPEventURLIsNeverSent(t *testing.T) { start := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC) end := time.Date(2026, 1, 1, 9, 30, 0, 0, time.UTC) - patch := buildGooglePatch("Standup", start, end, false, nil, "", "", "", "message://abc") + patch := buildGooglePatch("Standup", start, end, false, nil, "", "", "", "message://abc", true) if patch.Source != nil { 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, "", "", "", "", true), + 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"},"transparency":"opaque","source":null}`, + }, + { + name: "all-day sends a bare date, dateTime explicitly null and no zone at all", + patch: buildGooglePatch("Holiday", start, end, true, &sarajevo, "", "", "", "", true), + want: `{"summary":"Holiday","description":"","location":"","start":{"date":"2026-09-10","dateTime":null},"end":{"date":"2026-09-10","dateTime":null},"transparency":"opaque","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, "", "", "", "", true), + want: `{"summary":"Floats","description":"","location":"","start":{"date":null,"dateTime":"2026-09-10T14:00:00Z"},"end":{"date":null,"dateTime":"2026-09-10T15:00:00Z"},"transparency":"opaque","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", "", "", "", true), + 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"],"transparency":"opaque","source":null}`, + }, + { + name: "an Event URL becomes a source object", + patch: buildGooglePatch("Linked", start, end, false, &sarajevo, "", "", "", "https://example.com/x", true), + 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"},"transparency":"opaque","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", "", true), + 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"},"transparency":"opaque","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/server/internal/service/graph.go b/server/internal/service/graph.go index 72385e5..375f0c3 100644 --- a/server/internal/service/graph.go +++ b/server/internal/service/graph.go @@ -45,28 +45,34 @@ type Graph struct { JWTSecret []byte AttachmentStore *attachmentstore.Store - UserRepo *repository.UserRepository - SessionRepo *repository.SessionRepository - CalendarRepo *repository.CalendarRepository - SourceRepo *repository.SourceRepository - ConnectionRepo *repository.ConnectionRepository - ShareRepo *repository.CalendarShareRepository - GroupShareRepo *repository.CalendarGroupShareRepository - ColorOverrideRepo *repository.CalendarUserColorRepository - DefaultReminderRepo *repository.CalendarDefaultReminderRepository - EventRepo *repository.EventRepository - EventExceptionRepo *repository.EventExceptionRepository - EventReminderRepo *repository.EventReminderRepository - ExplicitReminderRepo *repository.EventReminderExplicitRepository - SyncRepo *repository.SyncRepository - AttachmentRepo *repository.AttachmentRepository - AttendeeRepo *repository.AttendeeRepository - WorkspaceRepo *repository.WorkspaceRepository - WorkspaceInviteRepo *repository.WorkspaceInviteRepository - GroupRepo *repository.GroupRepository - NotificationRepo *repository.NotificationRepository - AppPasswordRepo *repository.AppPasswordRepository - FiredReminderRepo *repository.FiredReminderRepository + UserRepo *repository.UserRepository + SessionRepo *repository.SessionRepository + CalendarRepo *repository.CalendarRepository + SourceRepo *repository.SourceRepository + ConnectionRepo *repository.ConnectionRepository + ShareRepo *repository.CalendarShareRepository + GroupShareRepo *repository.CalendarGroupShareRepository + ColorOverrideRepo *repository.CalendarUserColorRepository + ExposureRepo *repository.CalendarExposureRepository + DefaultReminderRepo *repository.CalendarDefaultReminderRepository + EventRepo *repository.EventRepository + EventExceptionRepo *repository.EventExceptionRepository + EventReminderRepo *repository.EventReminderRepository + ExplicitReminderRepo *repository.EventReminderExplicitRepository + SyncRepo *repository.SyncRepository + AttachmentRepo *repository.AttachmentRepository + AttendeeRepo *repository.AttendeeRepository + WorkspaceRepo *repository.WorkspaceRepository + WorkspaceInviteRepo *repository.WorkspaceInviteRepository + GroupRepo *repository.GroupRepository + CalendarSetRepo *repository.CalendarSetRepository + TaskListRepo *repository.TaskListRepository + TaskRepo *repository.TaskRepository + AvailabilityScheduleRepo *repository.AvailabilityScheduleRepository + BookingLinkRepo *repository.BookingLinkRepository + NotificationRepo *repository.NotificationRepository + AppPasswordRepo *repository.AppPasswordRepository + FiredReminderRepo *repository.FiredReminderRepository // OutboxRepo is built unconditionally (#290, ADR-0075): a Write-back push // needs somewhere to queue into regardless of whether this deployment has // SMTP configured, since it has nothing to do with mail. What varies by @@ -82,13 +88,26 @@ type Graph struct { // having. RateLimitRepo *repository.RateLimitAttemptRepository - Auth *AuthService - Accounts *AccountService - AppPasswords *AppPasswordService - Attachments *AttachmentService - Calendars *CalendarService - Events *EventService - Groups *GroupService + Auth *AuthService + Accounts *AccountService + AppPasswords *AppPasswordService + Attachments *AttachmentService + Calendars *CalendarService + Events *EventService + Groups *GroupService + CalendarSets *CalendarSetService + TaskLists *TaskListService + Tasks *TaskService + AvailabilitySchedules *AvailabilityScheduleService + BookingLinks *BookingLinkService + // PublicBookings serves the public Booking Link page (#324, ADR-0084, + // ADR-0087) — the app's first unauthenticated surface, resolving + // (Handle, Slug) rather than any authenticated scope. + PublicBookings *PublicBookingService + // PublicIndex serves the public index page (#325, ADR-0084) — a + // derived rendering of PublicBookings' own owner, built on top of it + // rather than beside it so the two share one Paused rule. + PublicIndex *PublicIndexService Imports *ImportService Notifications *NotificationService Subscriptions *SubscribeService @@ -101,6 +120,10 @@ type Graph struct { // (AuthHandler, httpauth.RequireCalDAVAuth) rather than from inside // either service. RateLimiter *AuthRateLimiter + // PublicBookingRateLimiter throttles the public Booking Link page + // (#324, ADR-0087), enforced from PublicBookingHandler the same way + // RateLimiter is enforced from AuthHandler. + PublicBookingRateLimiter *PublicBookingRateLimiter } // GraphOption is a build input that isn't part of config.Config, because it @@ -195,36 +218,49 @@ func NewGraph(sqlDB *sql.DB, cfg config.Config, opts ...GraphOption) (*Graph, er JWTSecret: built.jwtSecret, AttachmentStore: attachmentstore.New(cfg.DataDir), - UserRepo: repository.NewUserRepository(sqlDB), - SessionRepo: repository.NewSessionRepository(sqlDB), - CalendarRepo: repository.NewCalendarRepository(sqlDB), - SourceRepo: repository.NewSourceRepository(sqlDB), - ConnectionRepo: repository.NewConnectionRepository(sqlDB), - ShareRepo: repository.NewCalendarShareRepository(sqlDB), - GroupShareRepo: repository.NewCalendarGroupShareRepository(sqlDB), - ColorOverrideRepo: repository.NewCalendarUserColorRepository(sqlDB), - DefaultReminderRepo: repository.NewCalendarDefaultReminderRepository(sqlDB), - EventRepo: repository.NewEventRepository(sqlDB), - EventExceptionRepo: repository.NewEventExceptionRepository(sqlDB), - EventReminderRepo: repository.NewEventReminderRepository(sqlDB), - ExplicitReminderRepo: repository.NewEventReminderExplicitRepository(sqlDB), - SyncRepo: repository.NewSyncRepository(sqlDB), - AttachmentRepo: repository.NewAttachmentRepository(sqlDB), - AttendeeRepo: repository.NewAttendeeRepository(sqlDB), - WorkspaceRepo: repository.NewWorkspaceRepository(sqlDB), - WorkspaceInviteRepo: repository.NewWorkspaceInviteRepository(sqlDB), - GroupRepo: repository.NewGroupRepository(sqlDB), - NotificationRepo: repository.NewNotificationRepository(sqlDB), - AppPasswordRepo: repository.NewAppPasswordRepository(sqlDB), - FiredReminderRepo: repository.NewFiredReminderRepository(sqlDB), + UserRepo: repository.NewUserRepository(sqlDB), + SessionRepo: repository.NewSessionRepository(sqlDB), + CalendarRepo: repository.NewCalendarRepository(sqlDB), + SourceRepo: repository.NewSourceRepository(sqlDB), + ConnectionRepo: repository.NewConnectionRepository(sqlDB), + ShareRepo: repository.NewCalendarShareRepository(sqlDB), + GroupShareRepo: repository.NewCalendarGroupShareRepository(sqlDB), + ColorOverrideRepo: repository.NewCalendarUserColorRepository(sqlDB), + ExposureRepo: repository.NewCalendarExposureRepository(sqlDB), + DefaultReminderRepo: repository.NewCalendarDefaultReminderRepository(sqlDB), + EventRepo: repository.NewEventRepository(sqlDB), + EventExceptionRepo: repository.NewEventExceptionRepository(sqlDB), + EventReminderRepo: repository.NewEventReminderRepository(sqlDB), + ExplicitReminderRepo: repository.NewEventReminderExplicitRepository(sqlDB), + SyncRepo: repository.NewSyncRepository(sqlDB), + AttachmentRepo: repository.NewAttachmentRepository(sqlDB), + AttendeeRepo: repository.NewAttendeeRepository(sqlDB), + WorkspaceRepo: repository.NewWorkspaceRepository(sqlDB), + WorkspaceInviteRepo: repository.NewWorkspaceInviteRepository(sqlDB), + GroupRepo: repository.NewGroupRepository(sqlDB), + CalendarSetRepo: repository.NewCalendarSetRepository(sqlDB), + TaskListRepo: repository.NewTaskListRepository(sqlDB), + TaskRepo: repository.NewTaskRepository(sqlDB), + AvailabilityScheduleRepo: repository.NewAvailabilityScheduleRepository(sqlDB), + BookingLinkRepo: repository.NewBookingLinkRepository(sqlDB), + NotificationRepo: repository.NewNotificationRepository(sqlDB), + AppPasswordRepo: repository.NewAppPasswordRepository(sqlDB), + FiredReminderRepo: repository.NewFiredReminderRepository(sqlDB), } g.OutboxRepo = repository.NewOutboxRepository(sqlDB) 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.PublicBookingRateLimiter = NewPublicBookingRateLimiter(g.RateLimitRepo, cfg.PublicBookingRateLimitPerIP) + g.Workspaces = NewWorkspaceService(sqlDB, g.WorkspaceRepo, g.WorkspaceInviteRepo, g.CalendarRepo, g.ShareRepo, g.TaskListRepo) + g.TaskLists = NewTaskListService(sqlDB, g.TaskListRepo, g.TaskRepo) + g.Tasks = NewTaskService(g.TaskRepo, g.TaskListRepo) + g.AvailabilitySchedules = NewAvailabilityScheduleService(sqlDB, g.AvailabilityScheduleRepo, g.UserRepo) 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.WorkspaceRepo, g.GroupShareRepo, g.GroupRepo) + 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 @@ -237,8 +273,18 @@ func NewGraph(sqlDB *sql.DB, cfg config.Config, opts ...GraphOption) (*Graph, er mailOutbox = g.OutboxRepo } g.Events = NewEventService(sqlDB, g.EventRepo, g.EventExceptionRepo, g.EventReminderRepo, g.DefaultReminderRepo, g.ExplicitReminderRepo, g.SyncRepo, g.Calendars, g.UserRepo, g.AttachmentRepo, g.AttendeeRepo, g.WorkspaceRepo, g.GroupRepo, g.NotificationRepo, mailOutbox, g.OutboxRepo, g.ConnectionRepo, cfg.InviteRateLimitPerHour) + // BookingLinks needs Calendars for its own Access checks and Auth for the + // Handle auto-claim (#322, ADR-0084), so it's built after both. + g.BookingLinks = NewBookingLinkService(sqlDB, g.BookingLinkRepo, g.CalendarRepo, g.Calendars, g.AvailabilityScheduleRepo, g.EventRepo, g.TaskRepo, g.Auth) + // PublicBookings' own write path (Book, #326) needs Events built first — + // EventService.Create is where a confirmed booking is actually written. + // g.Auth doubles as Cancel's own signed cancel-token codec (#327, + // ADR-0087), mirroring ConnectionService's reuse of it for connect + // state. + g.PublicBookings = NewPublicBookingService(g.UserRepo, g.BookingLinkRepo, g.AvailabilityScheduleRepo, g.Calendars, g.BookingLinks, g.Events, g.EventRepo, g.OutboxRepo, g.Auth, cfg.SMTPConfigured()) + g.PublicIndex = NewPublicIndexService(g.UserRepo, g.BookingLinkRepo, g.PublicBookings) g.Attachments = NewAttachmentService(g.AttachmentRepo, g.EventRepo, g.Calendars, g.Events, g.AttachmentStore, cfg.MaxAttachmentsPerEvent) - g.Accounts = NewAccountService(sqlDB, g.UserRepo, g.SessionRepo, g.CalendarRepo, g.ShareRepo, g.WorkspaceRepo, g.Workspaces) + g.Accounts = NewAccountService(sqlDB, g.UserRepo, g.SessionRepo, g.CalendarRepo, g.ShareRepo, g.WorkspaceRepo, g.BookingLinkRepo, g.Workspaces) g.AppPasswords = NewAppPasswordService(g.AppPasswordRepo, g.UserRepo) g.Notifications = NewNotificationService(g.NotificationRepo) g.Users = NewUserService(g.UserRepo) @@ -260,7 +306,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/server/internal/service/import.go b/server/internal/service/import.go index dfe17ce..9f40a96 100644 --- a/server/internal/service/import.go +++ b/server/internal/service/import.go @@ -616,6 +616,7 @@ func seriesWriteFromImported(imported icalendar.ImportedSeries) SeriesWrite { Start: o.Start, End: o.End, AllDay: o.AllDay, + Busy: o.Busy, Tzid: o.Tzid, Reminders: o.Reminders, Color: o.Color, @@ -630,6 +631,7 @@ func seriesWriteFromImported(imported icalendar.ImportedSeries) SeriesWrite { Start: imported.Master.Start, End: imported.Master.End, AllDay: imported.Master.AllDay, + Busy: imported.Master.Busy, Tzid: imported.Master.Tzid, Rrule: imported.Rrule, Reminders: imported.Master.Reminders, diff --git a/server/internal/service/invitation.go b/server/internal/service/invitation.go index 249a794..6aac9c1 100644 --- a/server/internal/service/invitation.go +++ b/server/internal/service/invitation.go @@ -14,6 +14,9 @@ import ( type InvitationMailer interface { SendInvitation(to, fromName, replyTo, subject string, ics []byte) error SendCancellation(to, fromName, replyTo, subject string, ics []byte) error + // Send delivers a plain-text email — a BOOKING_NOTICE row's own delivery + // (#327, ADR-0087), which carries no iCalendar part to render. + Send(to, subject, body string) error } // InvitationSender turns one queued outbox.Message into an Invitation and @@ -42,19 +45,24 @@ func NewInvitationSender(events *EventService, mailer InvitationMailer, from str // process instead of just failing this one message. var ErrNoMailTransportConfigured = errors.New("no SMTP transport configured on this instance") -// Send dispatches msg to sendInvitation or sendCancellation by its Method -// (ADR-0059, #201). s.mailer is nil exactly when this instance has no SMTP -// transport configured — never a *mailer.SMTPMailer holding a nil pointer, -// which app.go's own construction guarantees by passing a bare nil -// InvitationMailer rather than a nil-valued concrete one. +// Send dispatches msg to sendInvitation, sendCancellation, or +// sendBookingNotice by its Method (ADR-0059, #201, #327). s.mailer is nil +// exactly when this instance has no SMTP transport configured — never a +// *mailer.SMTPMailer holding a nil pointer, which app.go's own construction +// guarantees by passing a bare nil InvitationMailer rather than a +// nil-valued concrete one. func (s *InvitationSender) Send(ctx context.Context, msg repository.OutboxMessage) error { if s.mailer == nil { return ErrNoMailTransportConfigured } - if msg.Method == repository.OutboxMethodCancel { + switch msg.Method { + case repository.OutboxMethodCancel: return s.sendCancellation(msg) + case repository.OutboxMethodBookingNotice: + return s.sendBookingNotice(msg) + default: + return s.sendInvitation(ctx, msg) } - return s.sendInvitation(ctx, msg) } // sendInvitation builds msg's Invitation from the Event and Attendee state @@ -137,3 +145,17 @@ func (s *InvitationSender) sendCancellation(msg repository.OutboxMessage) error } return nil } + +// sendBookingNotice delivers msg's BOOKING_NOTICE verbatim (#327, +// ADR-0087): a plain email whose subject/body were already rendered by +// PublicBookingService at enqueue time, so — like sendCancellation, unlike +// sendInvitation — nothing here is re-read from live Event state. +func (s *InvitationSender) sendBookingNotice(msg repository.OutboxMessage) error { + if msg.BookingNotice == nil || msg.RecipientEmail == nil { + return fmt.Errorf("booking notice outbox message %d carries no snapshot", msg.ID) + } + if err := s.mailer.Send(*msg.RecipientEmail, msg.BookingNotice.Subject, msg.BookingNotice.Body); err != nil { + return fmt.Errorf("send booking notice: %w", err) + } + return nil +} diff --git a/server/internal/service/invitation_outbox_test.go b/server/internal/service/invitation_outbox_test.go index 9f943c2..87ba336 100644 --- a/server/internal/service/invitation_outbox_test.go +++ b/server/internal/service/invitation_outbox_test.go @@ -304,6 +304,7 @@ func TestEventService_LoadInvitation_NotOKWhenAttendeeWasRemoved(t *testing.T) { type fakeInvitationMailer struct { calls []fakeInvitationCall cancelCalls []fakeInvitationCall + noticeCalls []fakeNoticeCall err error } @@ -312,6 +313,10 @@ type fakeInvitationCall struct { ics []byte } +type fakeNoticeCall struct { + to, subject, body string +} + func (f *fakeInvitationMailer) SendInvitation(to, fromName, replyTo, subject string, ics []byte) error { f.calls = append(f.calls, fakeInvitationCall{to, fromName, replyTo, subject, ics}) return f.err @@ -322,6 +327,11 @@ func (f *fakeInvitationMailer) SendCancellation(to, fromName, replyTo, subject s return f.err } +func (f *fakeInvitationMailer) Send(to, subject, body string) error { + f.noticeCalls = append(f.noticeCalls, fakeNoticeCall{to, subject, body}) + return f.err +} + func TestInvitationSender_Send_BuildsAndSendsTheInvitation(t *testing.T) { svc, _, ownerID, memberID, _, _, calendarID := newTestOutboxAttendeeService(t) ctx := context.Background() diff --git a/server/internal/service/public_booking.go b/server/internal/service/public_booking.go new file mode 100644 index 0000000..860c8d7 --- /dev/null +++ b/server/internal/service/public_booking.go @@ -0,0 +1,475 @@ +package service + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/url" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/XiovV/calich/server/internal/repository" +) + +var ( + // ErrBookingLinkPaused is returned by Book when the link isn't accepting + // bookings right now — explicitly Paused, no SMTP configured, or the + // host has lost Owner/Editor Access to the Book-into Calendar since the + // link was last saved (ADR-0087) — collapsed into the one answer isPaused + // already renders for Get/Slots, so a stranger never learns which. + ErrBookingLinkPaused = errors.New("this booking link is not accepting bookings") + // ErrInvalidVisitorName is returned by Book when the visitor's name is + // empty — the booking form's only other required field besides email + // (ADR-0087). + ErrInvalidVisitorName = errors.New("name is required") + // ErrSlotTaken is returned by Book when start no longer names a + // bookable slot once re-derived inside the write transaction — either + // another visitor just took it, or it was never a real slot to begin + // with (stale client state, a slot past the booking horizon, ...). The + // caller re-fetches slots and tries again (ADR-0087's "the loser is + // told the time was just taken and shown fresh slots"). + ErrSlotTaken = errors.New("that time was just taken") + // ErrInvalidCancelToken is returned by Cancel when token doesn't parse + // or verify against a signature this instance minted — a forged, + // truncated, or otherwise tampered cancel link (#327, ADR-0087). + ErrInvalidCancelToken = errors.New("invalid or expired cancel link") + // ErrBookingAlreadyStarted is returned by Cancel when the booked Event's + // own start is no longer in the future — a stale link cannot delete + // something that already happened (#327, ADR-0087). + ErrBookingAlreadyStarted = errors.New("this booking has already started and can no longer be cancelled") +) + +// bookingCancelCodec is AuthService's own signed-token pair +// (IssueBookingCancelToken/ParseBookingCancelToken, #327, ADR-0087) — +// PublicBookingService's own narrow seam onto it, mirroring +// connectStateCodec's relationship to ConnectionService. +type bookingCancelCodec interface { + IssueBookingCancelToken(eventID string) (string, error) + ParseBookingCancelToken(token string) (string, error) +} + +// PublicBookingLink is what a stranger holding a Booking Link's URL is told +// (#324, ADR-0084, ADR-0087) — deliberately narrower than repository. +// BookingLink: no id, no Book-into Calendar, no Conflict set, nothing that +// isn't shown on the page itself. Visibility itself is never exposed either +// — Paused collapses "explicitly Paused", "no SMTP configured" and "lost +// Book-into Access" into the one answer a visitor ever needs (ADR-0087: "A +// link behaves as Paused ... and Settings says why" — the why is for the +// host, in Settings, not for a stranger). +type PublicBookingLink struct { + HostName string + HostTimezone string + Title string + DurationMinutes int + Location string + Description string + BookingHorizonDays int + Paused bool +} + +// PublicBookingService serves the public Booking Link page (#324, ADR-0084, +// ADR-0087) — the app's first unauthenticated surface that touches calendar +// data. Every method resolves (handle, slug) itself rather than taking an +// id, and every failure to resolve is repository.ErrNotFound: an unknown +// Handle, an unknown Slug, and a reserved-word Handle are indistinguishable, +// so the namespace cannot enumerate Users (ADR-0084's Consequences). A +// Private link is not folded into that: it resolves and renders exactly +// like a Public one when reached by its own direct URL — only the derived +// index (#325) omits it. +type PublicBookingService struct { + users *repository.UserRepository + links *repository.BookingLinkRepository + schedules *repository.AvailabilityScheduleRepository + calendars *CalendarService + bookingLinks *BookingLinkService + // events is Book's own write path (#326, ADR-0087) — EventService.Create + // already does everything a booking needs: an Access-checked write onto + // the Book-into Calendar, an email-shaped Attendee invite (and its + // Invitation, when outbox is configured), and — for a writable Linked + // Calendar — a queued Write-back push. Get and Slots above never touch + // it. + events *EventService + // eventsRepo is Cancel's own read of the booked Event (#327, ADR-0087): + // a signed cancel link names an Event, not a (handle, slug), so there is + // no host Session to resolve it through the way every other method on + // this service does — this is a direct, unauthorized-by-design lookup, + // with the token's own signature the only gate. + eventsRepo *repository.EventRepository + // outbox queues the visitor's booking confirmation (carrying the signed + // cancel link) and the host's cancellation notice (#327, ADR-0087) — + // both a BOOKING_NOTICE row, never sent inline (ADR-0060). + outbox *repository.OutboxRepository + // cancelTokens mints and verifies Cancel's own signed link. + cancelTokens bookingCancelCodec + // smtpConfigured mirrors config.Config.SMTPConfigured() — publishing a + // Booking Link requires SMTP (ADR-0087), so every link behaves as + // Paused when this instance has none, regardless of what it's set to. + smtpConfigured bool +} + +func NewPublicBookingService(users *repository.UserRepository, links *repository.BookingLinkRepository, schedules *repository.AvailabilityScheduleRepository, calendars *CalendarService, bookingLinks *BookingLinkService, events *EventService, eventsRepo *repository.EventRepository, outbox *repository.OutboxRepository, cancelTokens bookingCancelCodec, smtpConfigured bool) *PublicBookingService { + return &PublicBookingService{ + users: users, + links: links, + schedules: schedules, + calendars: calendars, + bookingLinks: bookingLinks, + events: events, + eventsRepo: eventsRepo, + outbox: outbox, + cancelTokens: cancelTokens, + smtpConfigured: smtpConfigured, + } +} + +// Get resolves (handle, slug) into what the public page renders. +func (s *PublicBookingService) Get(ctx context.Context, handle, slug string) (PublicBookingLink, error) { + link, user, err := s.resolve(ctx, handle, slug) + if err != nil { + return PublicBookingLink{}, err + } + + schedule, err := s.schedules.GetByID(ctx, link.AvailabilityScheduleID, link.UserID) + if err != nil { + return PublicBookingLink{}, fmt.Errorf("get availability schedule: %w", err) + } + + paused, err := s.isPaused(ctx, link) + if err != nil { + return PublicBookingLink{}, err + } + + return PublicBookingLink{ + HostName: user.Name, + HostTimezone: schedule.Tzid, + Title: link.Title, + DurationMinutes: link.DurationMinutes, + Location: link.Location, + Description: link.Description, + BookingHorizonDays: link.BookingHorizonDays, + Paused: paused, + }, nil +} + +// Slots resolves (handle, slug) and derives its slots for (year, month), +// reusing the exact same derivation the authenticated /slots route runs +// (BookingLinkService.DeriveSlotsForLinkAndMonth) — the whole point of #323 +// deriving server-side (ADR-0085's "the public page has no Session, so slot +// derivation runs on the backend"). A Paused link (explicitly, no SMTP, or a +// lost Book-into Access) always answers no slots, without distinguishing why +// — ADR-0087's Access re-check happens here, at read time, not only at +// Create/Update. +func (s *PublicBookingService) Slots(ctx context.Context, handle, slug string, year int, month time.Month, now time.Time) ([]time.Time, error) { + if month < 1 || month > 12 { + return nil, ErrInvalidMonth + } + + link, _, err := s.resolve(ctx, handle, slug) + if err != nil { + return nil, err + } + + paused, err := s.isPaused(ctx, link) + if err != nil { + return nil, err + } + if paused { + return []time.Time{}, nil + } + + return s.bookingLinks.DeriveSlotsForLinkAndMonth(ctx, link, year, month, now) +} + +// BookingRequest is Book's input: the booking form's only two fields, both +// required (ADR-0087), plus the slot the visitor picked from Slots' own +// answer. +type BookingRequest struct { + Start time.Time + VisitorName string + VisitorEmail string +} + +// Book resolves (handle, slug) and confirms req.Start into a Busy Event on +// the Book-into Calendar (#326, ADR-0087): the visitor becomes an +// email-shaped Attendee (ADR-0058) and, once the write commits, receives the +// ordinary Invitation (ADR-0059) — EventService.Create's own attendee-invite +// path does both, unchanged. Access to the Book-into Calendar and SMTP are +// re-checked here via isPaused, exactly as Get and Slots already do, rather +// than trusted from whenever the link was last saved. The slot itself is +// re-derived a second time, inside Create's own write transaction +// (EventWrite.PreCommitCheck) — so a slot that was free when isPaused ran a +// moment ago but is taken by the time this transaction actually opens is +// still caught, and two visitors racing the same slot can never both win. +func (s *PublicBookingService) Book(ctx context.Context, handle, slug string, req BookingRequest, now time.Time, baseURL string) (repository.Event, error) { + link, host, err := s.resolve(ctx, handle, slug) + if err != nil { + return repository.Event{}, err + } + + paused, err := s.isPaused(ctx, link) + if err != nil { + return repository.Event{}, err + } + if paused { + return repository.Event{}, ErrBookingLinkPaused + } + + name := strings.TrimSpace(req.VisitorName) + if name == "" { + return repository.Event{}, ErrInvalidVisitorName + } + email, err := validateEmail(req.VisitorEmail) + if err != nil { + return repository.Event{}, err + } + + schedule, err := s.schedules.GetByID(ctx, link.AvailabilityScheduleID, link.UserID) + if err != nil { + return repository.Event{}, fmt.Errorf("get availability schedule: %w", err) + } + + start := req.Start + end := start.Add(time.Duration(link.DurationMinutes) * time.Minute) + tzid := schedule.Tzid + + write := EventWrite{ + CalendarID: link.BookIntoCalendarID, + Title: bookingEventTitle(link.Title, name), + Start: start, + End: end, + Busy: true, + Tzid: &tzid, + Description: link.Description, + Location: link.Location, + AttendeeEmails: []string{email}, + // A Linked Calendar is a legal Book-into target (ADR-0087) — Create's + // own guard otherwise refuses any Attendee on a Connection Source + // (ADR-0052); this is the one caller that opts back in, since the + // visitor's row stays local-only regardless (EventWrite's own doc + // comment on this field explains why that's still consistent with + // ADR-0052's reasoning). + AllowAttendeesOnConnectionSource: true, + // The slot re-derivation this method's own doc comment describes, + // run through BookingLinkService.SlotAvailable against tx-bound + // Event/Task repositories built from this very transaction's *sql.Tx + // — never s.bookingLinks' own pooled ones, which would read outside + // it and reopen the race this exists to close. + PreCommitCheck: func(ctx context.Context, tx *sql.Tx) error { + available, err := s.bookingLinks.SlotAvailable(ctx, tx, schedule, link, start, now) + if err != nil { + return fmt.Errorf("re-derive slot: %w", err) + } + if !available { + return ErrSlotTaken + } + return nil + }, + } + + event, err := s.events.Create(ctx, link.UserID, uuid.NewString(), write) + if err != nil { + if errors.Is(err, ErrSlotTaken) { + return repository.Event{}, ErrSlotTaken + } + if errors.Is(err, ErrCalendarNotFound) || errors.Is(err, ErrCalendarReadOnly) { + // The host lost Access to the Book-into Calendar (or it stopped + // resolving) between isPaused's own check above and this write + // landing — rare, but answered exactly as isPaused already + // would have (ADR-0087). + return repository.Event{}, ErrBookingLinkPaused + } + return repository.Event{}, fmt.Errorf("create booking event: %w", err) + } + + // The confirmation mail carrying the signed cancel link (#327, + // ADR-0087) — queued through the outbox (ADR-0060) rather than sent + // inline, alongside (never instead of) the ordinary Invitation + // EventService.Create's own attendee-invite path already queued above. + // The Event already committed by this point, so a failure here (an + // unexpected DB error, not a delivery failure — the outbox Worker's own + // retry/backoff owns those) surfaces as a 500 to the visitor even though + // the booking itself stands; a rare gap accepted rather than adding a + // second transaction spanning two independent writes. + token, err := s.cancelTokens.IssueBookingCancelToken(event.ID) + if err != nil { + return repository.Event{}, fmt.Errorf("issue booking cancel token: %w", err) + } + cancelURL := baseURL + "/cancel-booking?token=" + url.QueryEscape(token) + subject, body := bookingConfirmationNotice(host.Name, link.Title, link.Location, start, end, tzid, cancelURL) + if _, err := s.outbox.EnqueueBookingNotice(ctx, event.ID, email, subject, body); err != nil { + return repository.Event{}, fmt.Errorf("enqueue booking confirmation: %w", err) + } + + return event, nil +} + +// bookingConfirmationNotice renders the visitor's booking confirmation +// (#327, ADR-0087): what carries the signed cancel link. +func bookingConfirmationNotice(hostName, title, location string, start, end time.Time, tzid, cancelURL string) (subject, body string) { + subject = fmt.Sprintf("Booking confirmed: %s", title) + body = fmt.Sprintf("Your booking with %s is confirmed.\n\n%s\n%s\n", hostName, title, formatBookingWindow(start, end, tzid)) + if location != "" { + body += location + "\n" + } + body += fmt.Sprintf("\nNeed to cancel? %s\n", cancelURL) + return subject, body +} + +// bookingCancelledNotice renders the host's cancellation notice (#327, +// ADR-0087) — no Notification is raised for it (CONTEXT.md's three +// producers stay three); this mail is the only trace the host gets. +func bookingCancelledNotice(visitorName, visitorEmail, title string, start, end time.Time, tzid string) (subject, body string) { + subject = fmt.Sprintf("Cancelled: %s", title) + body = fmt.Sprintf("%s (%s) cancelled their booking.\n\n%s\n%s\n", visitorName, visitorEmail, title, formatBookingWindow(start, end, tzid)) + return subject, body +} + +// formatBookingWindow renders start-end in tzid's own wall-clock, falling +// back to UTC for an unparseable zone rather than failing the whole notice +// over a formatting detail. +func formatBookingWindow(start, end time.Time, tzid string) string { + loc, err := time.LoadLocation(tzid) + if err != nil { + loc = time.UTC + } + const layout = "Mon, Jan 2, 2006 15:04" + return fmt.Sprintf("%s - %s (%s)", start.In(loc).Format(layout), end.In(loc).Format("15:04"), tzid) +} + +// Cancel verifies token, refuses a booking that has already started, and +// deletes its Event (#327, ADR-0087) — the visitor's own signed link is the +// only gate here, since a visitor holds no Session to authorize this +// through any other way. Deletion reuses EventService.Delete wholesale +// (keyed on the Event's own CreatedBy as the host): the METHOD:CANCEL to +// the visitor's Attendee row, the write-back delete on a Linked Calendar, +// and the tombstone are all its existing, tested behavior. Idempotent: an +// already-cancelled (or never-existed) Event answers nil, not an error, so +// following the link twice never surfaces one. +func (s *PublicBookingService) Cancel(ctx context.Context, token string, now time.Time) error { + eventID, err := s.cancelTokens.ParseBookingCancelToken(token) + if err != nil { + return ErrInvalidCancelToken + } + + event, err := s.eventsRepo.GetByID(ctx, eventID) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return nil + } + return fmt.Errorf("get booked event for cancellation: %w", err) + } + if event.CreatedBy == nil { + return ErrInvalidCancelToken + } + hostID := *event.CreatedBy + + if !event.Start.After(now) { + return ErrBookingAlreadyStarted + } + + host, err := s.users.GetByID(ctx, hostID) + if err != nil { + return fmt.Errorf("get host for cancellation notice: %w", err) + } + + // Captured before Delete, which removes the Attendee row along with the + // Event itself. + var visitorName, visitorEmail string + if attendees, err := s.events.ListAttendees(ctx, hostID, event.ID); err == nil && len(attendees) > 0 { + visitorName, visitorEmail = attendees[0].Name, attendees[0].Email + } + + if err := s.events.Delete(ctx, hostID, event.ID); err != nil { + if errors.Is(err, repository.ErrNotFound) { + // Raced with another cancel following the same link — already + // gone, which is exactly what this call wanted. + return nil + } + return fmt.Errorf("delete cancelled booking event: %w", err) + } + + subject, body := bookingCancelledNotice(visitorName, visitorEmail, event.Title, event.Start, event.End, hostTzid(event)) + if _, err := s.outbox.EnqueueBookingNotice(ctx, event.ID, host.Email, subject, body); err != nil { + return fmt.Errorf("enqueue booking cancelled notice: %w", err) + } + return nil +} + +// hostTzid is the Anchor zone a cancellation notice renders event.Start/End +// in, falling back to UTC for a Floating booking Event — never actually nil +// in practice, since Book always sets one from the Schedule, but formatBookingWindow's +// own fallback would otherwise have to guess at an empty string instead. +func hostTzid(event repository.Event) string { + if event.Tzid != nil { + return *event.Tzid + } + return "Etc/UTC" +} + +// bookingEventTitle is a booking's Event title (ADR-0087's Decision: +// `title = "<link title> — <visitor name>"`). +func bookingEventTitle(linkTitle, visitorName string) string { + return linkTitle + " — " + visitorName +} + +// resolve looks up the Booking Link identified by (handle, slug), collapsing +// every way that can fail into repository.ErrNotFound: handle is reserved +// (#321, ADR-0084 — this namespace consumes service.ReservedHandles +// directly rather than keeping a second list), no User claims handle, or +// that User has no Booking Link with slug. Case-insensitivity for handle is +// the users.handle column's own COLLATE NOCASE (ADR-0084, mirroring Email); +// slug is compared as BookingLinkRepository.GetBySlug already does. +func (s *PublicBookingService) resolve(ctx context.Context, handle, slug string) (repository.BookingLink, repository.User, error) { + if IsReservedHandle(handle) { + return repository.BookingLink{}, repository.User{}, repository.ErrNotFound + } + + user, err := s.users.GetByHandle(ctx, handle) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return repository.BookingLink{}, repository.User{}, repository.ErrNotFound + } + return repository.BookingLink{}, repository.User{}, fmt.Errorf("get user by handle: %w", err) + } + + link, err := s.links.GetBySlug(ctx, user.ID, slug) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return repository.BookingLink{}, repository.User{}, repository.ErrNotFound + } + return repository.BookingLink{}, repository.User{}, fmt.Errorf("get booking link by slug: %w", err) + } + + return link, user, nil +} + +// isPaused reports whether link behaves as Paused (ADR-0087): its own +// Visibility says so, this instance has no SMTP transport configured, or +// its owner has lost Owner or Editor Access to its Book-into Calendar since +// it was last saved — re-checked here, at read time, rather than trusted +// from create/update time, since a Share can be revoked at any point +// afterward. +func (s *PublicBookingService) isPaused(ctx context.Context, link repository.BookingLink) (bool, error) { + if link.Visibility == "paused" { + return true, nil + } + if !s.smtpConfigured { + return true, nil + } + + access, calendar, err := s.calendars.Access(ctx, link.UserID, link.BookIntoCalendarID) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return true, nil + } + return false, fmt.Errorf("resolve book-into calendar access: %w", err) + } + if !access.CanWrite() || calendar.WorkspaceID != link.WorkspaceID { + return true, nil + } + + return false, nil +} diff --git a/server/internal/service/public_booking_book_test.go b/server/internal/service/public_booking_book_test.go new file mode 100644 index 0000000..1938575 --- /dev/null +++ b/server/internal/service/public_booking_book_test.go @@ -0,0 +1,355 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/XiovV/calich/server/internal/apptest" + "github.com/XiovV/calich/server/internal/config" + "github.com/XiovV/calich/server/internal/repository" +) + +// setUpPublicBookingFixture is setUpBookingLinkSlotsFixture's own sibling, +// built on an SMTP-configured Graph — Book requires SMTP (ADR-0087), which +// newTestGraph's own default config doesn't carry. +func setUpPublicBookingFixture(t *testing.T, cfg config.Config) (g *Graph, hostID, workspaceID, linkID int64, calendarID, handle string) { + t.Helper() + ctx := context.Background() + + g = newTestGraphWithConfig(t, cfg) + user, _, err := g.Auth.Bootstrap(ctx) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + workspaces, err := g.Workspaces.ListForUser(ctx, user.ID) + if err != nil || len(workspaces) != 1 { + t.Fatalf("list workspaces: %v (%d)", err, len(workspaces)) + } + workspaceID = workspaces[0].ID + + calendarID = "22222222-2222-2222-2222-222222222222" + if _, err := g.Calendars.Create(ctx, user.ID, workspaceID, calendarID, CalendarWrite{Name: "Work", Color: "#12809CFF"}); err != nil { + t.Fatalf("create calendar: %v", err) + } + + ranges := make([]repository.AvailabilityRange, 5) + for i, weekday := range []int{1, 2, 3, 4, 5} { + ranges[i] = repository.AvailabilityRange{Weekday: weekday, StartMinute: 9 * 60, EndMinute: 17 * 60} + } + schedule, err := g.AvailabilitySchedules.Create(ctx, user.ID, "Default", "Etc/UTC", ranges) + if err != nil { + t.Fatalf("create schedule: %v", err) + } + + link, err := g.BookingLinks.Create(ctx, user.ID, workspaceID, BookingLinkWrite{ + Title: "Intro call", Slug: "intro-call", DurationMinutes: 60, Visibility: "public", + AvailabilityScheduleID: schedule.ID, BookIntoCalendarID: calendarID, + Location: "Zoom", Description: "Let's chat", MinimumNoticeMinutes: 0, BookingHorizonDays: 30, + }) + if err != nil { + t.Fatalf("create booking link: %v", err) + } + + suggestion, err := g.Auth.SuggestHandle(ctx, user.ID) + if err != nil { + t.Fatalf("suggest handle: %v", err) + } + if _, err := g.Auth.UpdateHandle(ctx, user.ID, suggestion); err != nil { + t.Fatalf("claim handle: %v", err) + } + updatedUser, err := g.Auth.GetUser(ctx, user.ID) + if err != nil || updatedUser.Handle == nil { + t.Fatalf("expected claimed handle: %v", err) + } + + return g, user.ID, workspaceID, link.ID, calendarID, *updatedUser.Handle +} + +// firstBookableSlot picks a slot at least a full week out, so a test's own +// clock skew or slow CI run can never race "now" past it. +func firstBookableSlot(t *testing.T, g *Graph, hostID, workspaceID, linkID int64) time.Time { + t.Helper() + ctx := context.Background() + + now := time.Now().UTC() + monday := nextWeekdayUTC(now.AddDate(0, 0, 7), time.Monday) + slots, err := g.BookingLinks.DeriveSlotsForMonth(ctx, hostID, workspaceID, linkID, monday.Year(), monday.Month(), now) + if err != nil { + t.Fatalf("derive slots: %v", err) + } + if len(slots) == 0 { + t.Fatalf("expected at least one bookable slot") + } + return slots[0] +} + +func TestPublicBookingService_Book_CreatesBusyEventWithAttendee(t *testing.T) { + g, hostID, workspaceID, linkID, calendarID, handle := setUpPublicBookingFixture(t, apptest.SMTPConfig(t)) + ctx := context.Background() + link, err := g.BookingLinks.ListForUser(ctx, hostID, workspaceID) + if err != nil || len(link) != 1 { + t.Fatalf("list booking links: %v", err) + } + slot := firstBookableSlot(t, g, hostID, workspaceID, linkID) + + event, err := g.PublicBookings.Book(ctx, handle, link[0].Slug, BookingRequest{ + Start: slot, VisitorName: "Bob Visitor", VisitorEmail: "bob@example.com", + }, time.Now(), "https://example.com") + if err != nil { + t.Fatalf("book: %v", err) + } + + if event.CalendarID != calendarID { + t.Fatalf("expected the event on the book-into calendar %q, got %q", calendarID, event.CalendarID) + } + if !event.Busy { + t.Fatalf("expected a Busy event") + } + if event.Title != "Intro call — Bob Visitor" { + t.Fatalf("expected title %q, got %q", "Intro call — Bob Visitor", event.Title) + } + if event.Location != "Zoom" || event.Description != "Let's chat" { + t.Fatalf("expected the link's Location/Description copied, got %q / %q", event.Location, event.Description) + } + if event.Tzid == nil || *event.Tzid != "Etc/UTC" { + t.Fatalf("expected the Schedule's own zone as Anchor zone, got %v", event.Tzid) + } + if event.CreatedBy == nil || *event.CreatedBy != hostID { + t.Fatalf("expected the host as Organizer, got %v", event.CreatedBy) + } + if !event.Start.Equal(slot) || !event.End.Equal(slot.Add(time.Hour)) { + t.Fatalf("expected the event to span the requested slot, got %v-%v", event.Start, event.End) + } + + attendees, err := g.Events.ListAttendees(ctx, hostID, event.ID) + if err != nil { + t.Fatalf("list attendees: %v", err) + } + if len(attendees) != 1 || attendees[0].Email != "bob@example.com" || attendees[0].UserID != nil { + t.Fatalf("expected the visitor as a single email-shaped attendee, got %+v", attendees) + } + + messages, err := g.OutboxRepo.ListPending(ctx, 10) + if err != nil { + t.Fatalf("list pending outbox messages: %v", err) + } + found := false + for _, m := range messages { + if m.EventID == event.ID && m.RecipientEmail != nil && *m.RecipientEmail == "bob@example.com" { + found = true + } + } + if !found { + t.Fatalf("expected the visitor's Invitation queued in the outbox, got %+v", messages) + } +} + +func TestPublicBookingService_Book_RefusesWhenSlotAlreadyTaken(t *testing.T) { + g, hostID, workspaceID, linkID, _, handle := setUpPublicBookingFixture(t, apptest.SMTPConfig(t)) + ctx := context.Background() + links, err := g.BookingLinks.ListForUser(ctx, hostID, workspaceID) + if err != nil || len(links) != 1 { + t.Fatalf("list booking links: %v", err) + } + slot := firstBookableSlot(t, g, hostID, workspaceID, linkID) + + if _, err := g.PublicBookings.Book(ctx, handle, links[0].Slug, BookingRequest{ + Start: slot, VisitorName: "First Visitor", VisitorEmail: "first@example.com", + }, time.Now(), "https://example.com"); err != nil { + t.Fatalf("first booking: %v", err) + } + + _, err = g.PublicBookings.Book(ctx, handle, links[0].Slug, BookingRequest{ + Start: slot, VisitorName: "Second Visitor", VisitorEmail: "second@example.com", + }, time.Now(), "https://example.com") + if !errors.Is(err, ErrSlotTaken) { + t.Fatalf("expected ErrSlotTaken for a slot already booked, got %v", err) + } + + events, err := g.Events.List(ctx, hostID, nil, nil) + if err != nil { + t.Fatalf("list events: %v", err) + } + count := 0 + for _, e := range events { + if e.Start.Equal(slot) { + count++ + } + } + if count != 1 { + t.Fatalf("expected exactly one Event for the raced slot, got %d", count) + } +} + +func TestPublicBookingService_Book_RefusesUnavailableSlot(t *testing.T) { + g, hostID, workspaceID, _, _, handle := setUpPublicBookingFixture(t, apptest.SMTPConfig(t)) + ctx := context.Background() + links, err := g.BookingLinks.ListForUser(ctx, hostID, workspaceID) + if err != nil || len(links) != 1 { + t.Fatalf("list booking links: %v", err) + } + + // A Sunday at midnight UTC is outside the Mon-Fri 09:00-17:00 Schedule, + // so it was never a real slot to begin with. + sunday := nextWeekdayUTC(time.Now().UTC().AddDate(0, 0, 7), time.Sunday) + + _, err = g.PublicBookings.Book(ctx, handle, links[0].Slug, BookingRequest{ + Start: sunday, VisitorName: "Bob", VisitorEmail: "bob@example.com", + }, time.Now(), "https://example.com") + if !errors.Is(err, ErrSlotTaken) { + t.Fatalf("expected ErrSlotTaken for a slot outside the Schedule, got %v", err) + } +} + +func TestPublicBookingService_Book_RefusesWhenNoSMTPConfigured(t *testing.T) { + g, hostID, workspaceID, linkID, _, handle := setUpPublicBookingFixture(t, apptest.Config(t)) + ctx := context.Background() + links, err := g.BookingLinks.ListForUser(ctx, hostID, workspaceID) + if err != nil || len(links) != 1 { + t.Fatalf("list booking links: %v", err) + } + slot := firstBookableSlot(t, g, hostID, workspaceID, linkID) + + _, err = g.PublicBookings.Book(ctx, handle, links[0].Slug, BookingRequest{ + Start: slot, VisitorName: "Bob", VisitorEmail: "bob@example.com", + }, time.Now(), "https://example.com") + if !errors.Is(err, ErrBookingLinkPaused) { + t.Fatalf("expected ErrBookingLinkPaused with no SMTP configured, got %v", err) + } +} + +func TestPublicBookingService_Book_RequiresNameAndEmail(t *testing.T) { + g, hostID, workspaceID, linkID, _, handle := setUpPublicBookingFixture(t, apptest.SMTPConfig(t)) + ctx := context.Background() + links, err := g.BookingLinks.ListForUser(ctx, hostID, workspaceID) + if err != nil || len(links) != 1 { + t.Fatalf("list booking links: %v", err) + } + slot := firstBookableSlot(t, g, hostID, workspaceID, linkID) + + if _, err := g.PublicBookings.Book(ctx, handle, links[0].Slug, BookingRequest{ + Start: slot, VisitorName: " ", VisitorEmail: "bob@example.com", + }, time.Now(), "https://example.com"); !errors.Is(err, ErrInvalidVisitorName) { + t.Fatalf("expected ErrInvalidVisitorName for a blank name, got %v", err) + } + + if _, err := g.PublicBookings.Book(ctx, handle, links[0].Slug, BookingRequest{ + Start: slot, VisitorName: "Bob", VisitorEmail: "not-an-email", + }, time.Now(), "https://example.com"); !errors.Is(err, ErrInvalidEmail) { + t.Fatalf("expected ErrInvalidEmail for a malformed email, got %v", err) + } +} + +// TestPublicBookingService_Book_LinkedCalendarQueuesWriteBack covers +// ADR-0087's "A Linked Calendar is a legal Book-into target ... the visitor +// is confirmed before the push lands": booking onto a writable Connection +// Source must still create the Event and its email-shaped Attendee (local +// only — never mirrored to the Provider), and queue an ordinary Write-back +// push for the Event's own fields, exactly as an authenticated Create would. +// This exercises EventWrite.AllowAttendeesOnConnectionSource, the one seam +// that lets a Booking Link's Attendee coexist with Create's usual "no +// Attendees on a Connection Source" refusal (ADR-0052). +func TestPublicBookingService_Book_LinkedCalendarQueuesWriteBack(t *testing.T) { + g := newTestGraphWithConfig(t, apptest.SMTPConfig(t)) + ctx := context.Background() + userID, calendarID := newTestLinkedCalendar(t, g, repository.SourceModeWritable) + + workspaces, err := g.Workspaces.ListForUser(ctx, userID) + if err != nil || len(workspaces) != 1 { + t.Fatalf("list workspaces: %v (%d)", err, len(workspaces)) + } + workspaceID := workspaces[0].ID + + ranges := make([]repository.AvailabilityRange, 5) + for i, weekday := range []int{1, 2, 3, 4, 5} { + ranges[i] = repository.AvailabilityRange{Weekday: weekday, StartMinute: 9 * 60, EndMinute: 17 * 60} + } + schedule, err := g.AvailabilitySchedules.Create(ctx, userID, "Default", "Etc/UTC", ranges) + if err != nil { + t.Fatalf("create schedule: %v", err) + } + + link, err := g.BookingLinks.Create(ctx, userID, workspaceID, BookingLinkWrite{ + Title: "Intro call", Slug: "intro-call", DurationMinutes: 60, Visibility: "public", + AvailabilityScheduleID: schedule.ID, BookIntoCalendarID: calendarID, + MinimumNoticeMinutes: 0, BookingHorizonDays: 30, + }) + if err != nil { + t.Fatalf("create booking link: %v", err) + } + + suggestion, err := g.Auth.SuggestHandle(ctx, userID) + if err != nil { + t.Fatalf("suggest handle: %v", err) + } + if _, err := g.Auth.UpdateHandle(ctx, userID, suggestion); err != nil { + t.Fatalf("claim handle: %v", err) + } + user, err := g.Auth.GetUser(ctx, userID) + if err != nil || user.Handle == nil { + t.Fatalf("expected claimed handle: %v", err) + } + + slot := firstBookableSlot(t, g, userID, workspaceID, link.ID) + + event, err := g.PublicBookings.Book(ctx, *user.Handle, link.Slug, BookingRequest{ + Start: slot, VisitorName: "Bob Visitor", VisitorEmail: "bob@example.com", + }, time.Now(), "https://example.com") + if err != nil { + t.Fatalf("expected booking onto a writable Linked Calendar to succeed, got: %v", err) + } + + attendees, err := g.Events.ListAttendees(ctx, userID, event.ID) + if err != nil { + t.Fatalf("list attendees: %v", err) + } + if len(attendees) != 1 || attendees[0].Email != "bob@example.com" { + t.Fatalf("expected the visitor as a local-only attendee, got %+v", attendees) + } + + messages, err := g.OutboxRepo.ListPending(ctx, 10) + if err != nil { + t.Fatalf("list pending outbox messages: %v", err) + } + foundWriteBack := false + for _, m := range messages { + if m.EventID == event.ID && m.Kind == repository.OutboxKindWriteBack { + foundWriteBack = true + } + } + if !foundWriteBack { + t.Fatalf("expected a queued Write-back push for the booking event, got %+v", messages) + } +} + +func TestPublicBookingService_Book_RefusesWhenBookIntoCalendarAccessLost(t *testing.T) { + g, hostID, workspaceID, linkID, _, handle := setUpPublicBookingFixture(t, apptest.SMTPConfig(t)) + ctx := context.Background() + links, err := g.BookingLinks.ListForUser(ctx, hostID, workspaceID) + if err != nil || len(links) != 1 { + t.Fatalf("list booking links: %v", err) + } + slot := firstBookableSlot(t, g, hostID, workspaceID, linkID) + + // Simulate the host losing Access the same way + // TestPublicBookingHandler_Get_PausedWhenBookIntoCalendarInAnotherWorkspace + // does: mismatch the stored link's workspace_id against its own + // Book-into Calendar's actual Workspace. + otherWorkspace, err := g.Workspaces.CreateForOwner(ctx, hostID, "Other") + if err != nil { + t.Fatalf("create second workspace: %v", err) + } + + if _, err := g.DB.Exec("UPDATE booking_links SET workspace_id = ? WHERE id = ?", otherWorkspace.ID, linkID); err != nil { + t.Fatalf("mismatch booking link's workspace: %v", err) + } + + _, err = g.PublicBookings.Book(ctx, handle, links[0].Slug, BookingRequest{ + Start: slot, VisitorName: "Bob", VisitorEmail: "visitor@example.com", + }, time.Now(), "https://example.com") + if !errors.Is(err, ErrBookingLinkPaused) { + t.Fatalf("expected ErrBookingLinkPaused once the Book-into Calendar sits outside the link's Workspace, got %v", err) + } +} diff --git a/server/internal/service/public_booking_cancel_test.go b/server/internal/service/public_booking_cancel_test.go new file mode 100644 index 0000000..2c17b69 --- /dev/null +++ b/server/internal/service/public_booking_cancel_test.go @@ -0,0 +1,273 @@ +package service + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/XiovV/calich/server/internal/apptest" + "github.com/XiovV/calich/server/internal/repository" +) + +// bookForCancelTest books firstBookableSlot and returns the created Event, +// the fixture's own graph/host/workspace, and the visitor's email — the +// shared setup every Cancel test starts from. +func bookForCancelTest(t *testing.T) (g *Graph, hostID, workspaceID int64, event repository.Event) { + t.Helper() + ctx := context.Background() + + g, hostID, workspaceID, linkID, _, handle := setUpPublicBookingFixture(t, apptest.SMTPConfig(t)) + links, err := g.BookingLinks.ListForUser(ctx, hostID, workspaceID) + if err != nil || len(links) != 1 { + t.Fatalf("list booking links: %v", err) + } + slot := firstBookableSlot(t, g, hostID, workspaceID, linkID) + + event, err = g.PublicBookings.Book(ctx, handle, links[0].Slug, BookingRequest{ + Start: slot, VisitorName: "Bob Visitor", VisitorEmail: "bob@example.com", + }, time.Now(), "https://cal.example.com") + if err != nil { + t.Fatalf("book: %v", err) + } + return g, hostID, workspaceID, event +} + +func TestPublicBookingService_Book_QueuesConfirmationWithCancelLink(t *testing.T) { + g, _, _, event := bookForCancelTest(t) + ctx := context.Background() + + messages, err := g.OutboxRepo.ListPending(ctx, 10) + if err != nil { + t.Fatalf("list pending outbox messages: %v", err) + } + + var notice *repository.OutboxMessage + for i, m := range messages { + if m.EventID == event.ID && m.Method == repository.OutboxMethodBookingNotice { + notice = &messages[i] + } + } + if notice == nil { + t.Fatalf("expected a queued BOOKING_NOTICE confirmation, got %+v", messages) + } + if notice.RecipientEmail == nil || *notice.RecipientEmail != "bob@example.com" { + t.Fatalf("expected the confirmation addressed to the visitor, got %+v", notice.RecipientEmail) + } + if notice.BookingNotice == nil { + t.Fatalf("expected a BookingNotice snapshot") + } + if !strings.Contains(notice.BookingNotice.Body, "https://cal.example.com/cancel-booking?token=") { + t.Fatalf("expected the confirmation body to carry a signed cancel link, got %q", notice.BookingNotice.Body) + } +} + +func TestPublicBookingService_Cancel_DeletesEventAndFreesSlot(t *testing.T) { + g, hostID, _, event := bookForCancelTest(t) + ctx := context.Background() + + token, err := g.Auth.IssueBookingCancelToken(event.ID) + if err != nil { + t.Fatalf("issue cancel token: %v", err) + } + + if err := g.PublicBookings.Cancel(ctx, token, time.Now()); err != nil { + t.Fatalf("cancel: %v", err) + } + + if _, err := g.Events.Get(ctx, hostID, event.ID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("expected the cancelled event to be gone, got %v", err) + } +} + +func TestPublicBookingService_Cancel_EmitsAttendeeCancelAndHostNotice(t *testing.T) { + g, _, _, event := bookForCancelTest(t) + ctx := context.Background() + + token, err := g.Auth.IssueBookingCancelToken(event.ID) + if err != nil { + t.Fatalf("issue cancel token: %v", err) + } + if err := g.PublicBookings.Cancel(ctx, token, time.Now()); err != nil { + t.Fatalf("cancel: %v", err) + } + + messages, err := g.OutboxRepo.ListPending(ctx, 20) + if err != nil { + t.Fatalf("list pending outbox messages: %v", err) + } + + foundAttendeeCancel := false + foundHostNotice := false + for _, m := range messages { + if m.EventID != event.ID { + continue + } + if m.Method == repository.OutboxMethodCancel && m.RecipientEmail != nil && *m.RecipientEmail == "bob@example.com" { + foundAttendeeCancel = true + } + if m.Method == repository.OutboxMethodBookingNotice && m.RecipientEmail != nil && *m.RecipientEmail == "admin@example.com" { + foundHostNotice = true + } + } + if !foundAttendeeCancel { + t.Fatalf("expected a METHOD:CANCEL queued to the visitor, got %+v", messages) + } + if !foundHostNotice { + t.Fatalf("expected a cancellation notice queued to the host, got %+v", messages) + } +} + +func TestPublicBookingService_Cancel_RefusesBadSignature(t *testing.T) { + g, _, _, event := bookForCancelTest(t) + ctx := context.Background() + + err := g.PublicBookings.Cancel(ctx, "not-a-real-token", time.Now()) + if !errors.Is(err, ErrInvalidCancelToken) { + t.Fatalf("expected ErrInvalidCancelToken for a garbage token, got %v", err) + } + + // A token signed for a different purpose (or under a different key) + // must be refused the same way — only a real cancel token names an + // Event this method is willing to delete. + otherToken, err := g.Auth.IssueConnectState(1) + if err != nil { + t.Fatalf("issue connect state: %v", err) + } + if err := g.PublicBookings.Cancel(ctx, otherToken, time.Now()); !errors.Is(err, ErrInvalidCancelToken) { + t.Fatalf("expected ErrInvalidCancelToken for a token signed for a different purpose, got %v", err) + } + + // Sanity: the real token for this fixture's own event still works. + token, err := g.Auth.IssueBookingCancelToken(event.ID) + if err != nil { + t.Fatalf("issue cancel token: %v", err) + } + if err := g.PublicBookings.Cancel(ctx, token, time.Now()); err != nil { + t.Fatalf("expected the real token to cancel cleanly, got %v", err) + } +} + +func TestPublicBookingService_Cancel_RefusesAfterEventStarted(t *testing.T) { + g, hostID, _, event := bookForCancelTest(t) + ctx := context.Background() + + token, err := g.Auth.IssueBookingCancelToken(event.ID) + if err != nil { + t.Fatalf("issue cancel token: %v", err) + } + + // now is after the booked slot's own start — as if the visitor waited + // past the meeting before following the link. + after := event.Start.Add(time.Hour) + if err := g.PublicBookings.Cancel(ctx, token, after); !errors.Is(err, ErrBookingAlreadyStarted) { + t.Fatalf("expected ErrBookingAlreadyStarted, got %v", err) + } + + if _, err := g.Events.Get(ctx, hostID, event.ID); err != nil { + t.Fatalf("expected the event to survive a refused cancellation, got %v", err) + } +} + +func TestPublicBookingService_Cancel_IsIdempotent(t *testing.T) { + g, _, _, event := bookForCancelTest(t) + ctx := context.Background() + + token, err := g.Auth.IssueBookingCancelToken(event.ID) + if err != nil { + t.Fatalf("issue cancel token: %v", err) + } + + if err := g.PublicBookings.Cancel(ctx, token, time.Now()); err != nil { + t.Fatalf("first cancel: %v", err) + } + if err := g.PublicBookings.Cancel(ctx, token, time.Now()); err != nil { + t.Fatalf("expected the second cancel to be a no-op, got %v", err) + } +} + +func TestPublicBookingService_Cancel_LinkedCalendarQueuesDeleteWriteBack(t *testing.T) { + g := newTestGraphWithConfig(t, apptest.SMTPConfig(t)) + ctx := context.Background() + userID, calendarID := newTestLinkedCalendar(t, g, repository.SourceModeWritable) + + workspaces, err := g.Workspaces.ListForUser(ctx, userID) + if err != nil || len(workspaces) != 1 { + t.Fatalf("list workspaces: %v (%d)", err, len(workspaces)) + } + workspaceID := workspaces[0].ID + + ranges := make([]repository.AvailabilityRange, 5) + for i, weekday := range []int{1, 2, 3, 4, 5} { + ranges[i] = repository.AvailabilityRange{Weekday: weekday, StartMinute: 9 * 60, EndMinute: 17 * 60} + } + schedule, err := g.AvailabilitySchedules.Create(ctx, userID, "Default", "Etc/UTC", ranges) + if err != nil { + t.Fatalf("create schedule: %v", err) + } + + link, err := g.BookingLinks.Create(ctx, userID, workspaceID, BookingLinkWrite{ + Title: "Intro call", Slug: "intro-call", DurationMinutes: 60, Visibility: "public", + AvailabilityScheduleID: schedule.ID, BookIntoCalendarID: calendarID, + MinimumNoticeMinutes: 0, BookingHorizonDays: 30, + }) + if err != nil { + t.Fatalf("create booking link: %v", err) + } + + suggestion, err := g.Auth.SuggestHandle(ctx, userID) + if err != nil { + t.Fatalf("suggest handle: %v", err) + } + if _, err := g.Auth.UpdateHandle(ctx, userID, suggestion); err != nil { + t.Fatalf("claim handle: %v", err) + } + user, err := g.Auth.GetUser(ctx, userID) + if err != nil || user.Handle == nil { + t.Fatalf("expected claimed handle: %v", err) + } + + slot := firstBookableSlot(t, g, userID, workspaceID, link.ID) + event, err := g.PublicBookings.Book(ctx, *user.Handle, link.Slug, BookingRequest{ + Start: slot, VisitorName: "Bob Visitor", VisitorEmail: "bob@example.com", + }, time.Now(), "https://cal.example.com") + if err != nil { + t.Fatalf("book: %v", err) + } + + token, err := g.Auth.IssueBookingCancelToken(event.ID) + if err != nil { + t.Fatalf("issue cancel token: %v", err) + } + if err := g.PublicBookings.Cancel(ctx, token, time.Now()); err != nil { + t.Fatalf("cancel: %v", err) + } + + messages, err := g.OutboxRepo.ListPending(ctx, 20) + if err != nil { + t.Fatalf("list pending outbox messages: %v", err) + } + foundDelete := false + for _, m := range messages { + if m.EventID == event.ID && m.Kind == repository.OutboxKindWriteBack && m.Method == repository.OutboxMethodDelete { + foundDelete = true + } + } + if !foundDelete { + t.Fatalf("expected a queued delete Write-back for the cancelled booking, got %+v", messages) + } +} + +func TestPublicBookingService_Cancel_UnknownTokenEventIsANoop(t *testing.T) { + g, _, _, _ := bookForCancelTest(t) + ctx := context.Background() + + token, err := g.Auth.IssueBookingCancelToken("00000000-0000-0000-0000-000000000000") + if err != nil { + t.Fatalf("issue cancel token: %v", err) + } + if err := g.PublicBookings.Cancel(ctx, token, time.Now()); err != nil { + t.Fatalf("expected a token naming a nonexistent event to be a no-op, got %v", err) + } +} diff --git a/server/internal/service/public_booking_rate_limiter.go b/server/internal/service/public_booking_rate_limiter.go new file mode 100644 index 0000000..1cc7e13 --- /dev/null +++ b/server/internal/service/public_booking_rate_limiter.go @@ -0,0 +1,59 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/XiovV/calich/server/internal/repository" +) + +// publicBookingRateLimitWindow is PublicBookingRateLimiter's rolling window +// (#324, ADR-0087), fixed like AuthRateLimiter's own windows — only the +// per-IP ceiling is env-configurable (config.Config.PublicBookingRateLimitPerIP). +const publicBookingRateLimitWindow = time.Minute + +// ErrPublicBookingRateLimitExceeded mirrors repository.ErrRateLimited so +// handlers only need to import this package's sentinels. +var ErrPublicBookingRateLimitExceeded = repository.ErrRateLimited + +// PublicBookingRateLimiter throttles the public Booking Link page (#324, +// ADR-0087) — the first unauthenticated surface that reads calendar data. +// IP-only, unlike AuthRateLimiter's email+IP pair: there is no credential or +// account being guessed here, only a stranger's own browsing, so the only +// key available is the caller's address. Deliberately enforced from the HTTP +// layer (PublicBookingHandler), mirroring AuthRateLimiter's own posture. +type PublicBookingRateLimiter struct { + repo *repository.RateLimitAttemptRepository + maxPerIP int +} + +func NewPublicBookingRateLimiter(repo *repository.RateLimitAttemptRepository, maxPerIP int) *PublicBookingRateLimiter { + return &PublicBookingRateLimiter{repo: repo, maxPerIP: maxPerIP} +} + +// Check refuses with ErrPublicBookingRateLimitExceeded once ip's own +// rolling-window count has reached its ceiling. +func (l *PublicBookingRateLimiter) Check(ctx context.Context, ip string) error { + since := time.Now().Add(-publicBookingRateLimitWindow) + + count, err := l.repo.CountSince(ctx, repository.RateLimitScopePublicBooking, repository.RateLimitKeyIP, ip, since) + if err != nil { + return fmt.Errorf("count public booking attempts: %w", err) + } + if count >= l.maxPerIP { + return ErrPublicBookingRateLimitExceeded + } + return nil +} + +// Record charges one call to ip's bucket — every call, not just a failed +// one, since what's throttled is how often the page is loaded at all +// (mirroring Register's own AuthRateLimiter.RecordRegisterAttempt). +func (l *PublicBookingRateLimiter) Record(ctx context.Context, ip string) error { + olderThan := time.Now().Add(-publicBookingRateLimitWindow) + if err := l.repo.Record(ctx, repository.RateLimitScopePublicBooking, repository.RateLimitKeyIP, ip, olderThan); err != nil { + return fmt.Errorf("record public booking attempt: %w", err) + } + return nil +} diff --git a/server/internal/service/public_index.go b/server/internal/service/public_index.go new file mode 100644 index 0000000..cb4d684 --- /dev/null +++ b/server/internal/service/public_index.go @@ -0,0 +1,90 @@ +// public_index.go implements PublicIndexService: the derived rendering +// behind /:handle (#325, ADR-0084) — one User's Name and every Public +// Booking Link they hold, unioned across every Workspace they belong to. +// There is no row behind it, in exactly the sense ADR-0083's Task bucket +// and Occurrence are computed rather than stored. +package service + +import ( + "context" + "errors" + "fmt" + + "github.com/XiovV/calich/server/internal/repository" +) + +// PublicIndexLink is one entry on the index — just enough to list an offer +// and let a visitor reach it: Title, Duration, and the Slug that addresses +// it under the same Handle. +type PublicIndexLink struct { + Slug string + Title string + DurationMinutes int +} + +// PublicIndex is what /:handle renders. HostName is deliberately empty +// whenever Links is empty: an unknown Handle, a reserved-word Handle, and a +// real Handle whose links are all Private, explicitly Paused, or filtered +// out by PublicBookingService's own Paused rule (ADR-0087, e.g. no SMTP +// configured) must render identically, or the index becomes a second door +// into the User-enumeration hole #324 already closed for the per-link page. +type PublicIndex struct { + HostName string + Links []PublicIndexLink +} + +// PublicIndexService serves the public index page (#325, ADR-0084). It +// reuses PublicBookingService's own Paused rule wholesale rather than +// keeping a second copy of it — the issue's own "shares the public route +// namespace and refusal behaviour" against #324. +type PublicIndexService struct { + users *repository.UserRepository + links *repository.BookingLinkRepository + publicBookings *PublicBookingService +} + +func NewPublicIndexService(users *repository.UserRepository, links *repository.BookingLinkRepository, publicBookings *PublicBookingService) *PublicIndexService { + return &PublicIndexService{users: users, links: links, publicBookings: publicBookings} +} + +// Get resolves handle into the index it renders. Every way resolution can +// fail to name a real, listable User collapses into the same zero-value +// PublicIndex{} rather than an error — an unknown Handle, a reserved-word +// Handle, a User with no Public links, or one whose Public links are all +// Paused — so the caller always answers 200 with the same shape. +func (s *PublicIndexService) Get(ctx context.Context, handle string) (PublicIndex, error) { + if IsReservedHandle(handle) { + return PublicIndex{}, nil + } + + user, err := s.users.GetByHandle(ctx, handle) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return PublicIndex{}, nil + } + return PublicIndex{}, fmt.Errorf("get user by handle: %w", err) + } + + links, err := s.links.ListPublicForUser(ctx, user.ID) + if err != nil { + return PublicIndex{}, fmt.Errorf("list public booking links: %w", err) + } + + visible := make([]PublicIndexLink, 0, len(links)) + for _, link := range links { + paused, err := s.publicBookings.isPaused(ctx, link) + if err != nil { + return PublicIndex{}, err + } + if paused { + continue + } + visible = append(visible, PublicIndexLink{Slug: link.Slug, Title: link.Title, DurationMinutes: link.DurationMinutes}) + } + + if len(visible) == 0 { + return PublicIndex{}, nil + } + + return PublicIndex{HostName: user.Name, Links: visible}, nil +} diff --git a/server/internal/service/reconcile.go b/server/internal/service/reconcile.go index cd24ed4..a8f5a93 100644 --- a/server/internal/service/reconcile.go +++ b/server/internal/service/reconcile.go @@ -186,7 +186,7 @@ func seriesContentEqual(a, b SeriesWrite) bool { if !a.Start.Equal(b.Start) || !a.End.Equal(b.End) { return false } - if a.AllDay != b.AllDay || a.Rrule != b.Rrule { + if a.AllDay != b.AllDay || a.Busy != b.Busy || a.Rrule != b.Rrule { return false } if !stringPtrEqual(a.Tzid, b.Tzid) { @@ -284,7 +284,7 @@ func overrideContentEqual(a, b OverrideWrite) bool { if !a.Start.Equal(b.Start) || !a.End.Equal(b.End) { return false } - if a.AllDay != b.AllDay { + if a.AllDay != b.AllDay || a.Busy != b.Busy { return false } if !stringPtrEqual(a.Tzid, b.Tzid) { diff --git a/server/internal/service/reconcile_delta.go b/server/internal/service/reconcile_delta.go index 011352f..46be4c8 100644 --- a/server/internal/service/reconcile_delta.go +++ b/server/internal/service/reconcile_delta.go @@ -124,6 +124,7 @@ func mergeDeltaChange(base SeriesWrite, change DeltaSeriesChange) SeriesWrite { merged.Start = m.Start merged.End = m.End merged.AllDay = m.AllDay + merged.Busy = m.Busy merged.Tzid = m.Tzid merged.Rrule = m.Rrule merged.ProviderEtag = m.ProviderEtag diff --git a/server/internal/service/reserved_handles.go b/server/internal/service/reserved_handles.go new file mode 100644 index 0000000..e1f27cc --- /dev/null +++ b/server/internal/service/reserved_handles.go @@ -0,0 +1,41 @@ +package service + +import "strings" + +// ReservedHandles is the single source of truth for every top-level path +// segment this app itself routes on (#321, ADR-0084) — a Handle sits in the +// same namespace as the app's own routes (`/:handle`, `/:handle/:slug`), so +// claiming one of these would shadow a real route rather than publish a +// page. The public router (#324, #325) will consume this list directly +// rather than keeping one of its own — a second list would drift, and a +// future top-level route added to the router without also being added here +// would silently shadow somebody's published page. Every entry beyond the +// app's actual current routes (admin, help, about, new) is deliberate +// future-proofing named in ADR-0084's Decision. +var ReservedHandles = map[string]bool{ + "login": true, + "register": true, + "settings": true, + "api": true, + "book": true, + "assets": true, + "static": true, + "accept-workspace-invite": true, + // cancel-booking (#327, ADR-0087): the frontend page a booking's signed + // cancel link opens, sibling to accept-workspace-invite above. + "cancel-booking": true, + "admin": true, + "help": true, + "about": true, + "new": true, +} + +// IsReservedHandle reports whether handle collides with a path the app +// already routes on, compared case-insensitively like every other Handle +// comparison (ADR-0084). Callers are expected to have already normalized +// handle (lowercased, trimmed) via validateHandle/normalizeHandle; the +// lowercasing here is a second, cheap line of defense rather than the only +// one. +func IsReservedHandle(handle string) bool { + return ReservedHandles[strings.ToLower(handle)] +} diff --git a/server/internal/service/subscribe.go b/server/internal/service/subscribe.go index 38da9e7..e625ee0 100644 --- a/server/internal/service/subscribe.go +++ b/server/internal/service/subscribe.go @@ -670,6 +670,7 @@ func existingSeriesFromMasters(masters []repository.Event, overridesByParent map Start: m.Start, End: m.End, AllDay: m.AllDay, + Busy: m.Busy, Tzid: m.Tzid, Rrule: m.Rrule, Exdates: m.Exdates, @@ -691,6 +692,7 @@ func existingSeriesFromMasters(masters []repository.Event, overridesByParent map Start: o.Start, End: o.End, AllDay: o.AllDay, + Busy: o.Busy, Tzid: o.Tzid, Reminders: storedReminders[o.ID], Color: o.Color, diff --git a/server/internal/service/task.go b/server/internal/service/task.go new file mode 100644 index 0000000..be5b479 --- /dev/null +++ b/server/internal/service/task.go @@ -0,0 +1,211 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "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") + +// 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") + +// ErrInvalidTaskDuration is returned by TaskService.SetTimeBlock when +// durationMinutes isn't positive. +var ErrInvalidTaskDuration = errors.New("task time block duration must be positive") + +// 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 +} + +// 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 +} + +// SetTimeBlock sets id's Time block, scoped to userID and workspaceID — the +// grid drop's own write (#313, ADR-0083): a Time block at start with +// durationMinutes, independent of the Deadline (never touches due). +func (s *TaskService) SetTimeBlock(ctx context.Context, userID, workspaceID, id int64, start time.Time, durationMinutes int) (repository.Task, error) { + if durationMinutes <= 0 { + return repository.Task{}, ErrInvalidTaskDuration + } + + task, err := s.tasks.SetTimeBlock(ctx, id, userID, workspaceID, start, durationMinutes) + if err != nil { + return repository.Task{}, fmt.Errorf("set task time block: %w", err) + } + return task, nil +} + +// ClearTimeBlock clears id's Time block, scoped to userID and workspaceID. +func (s *TaskService) ClearTimeBlock(ctx context.Context, userID, workspaceID, id int64) (repository.Task, error) { + task, err := s.tasks.ClearTimeBlock(ctx, id, userID, workspaceID) + if err != nil { + return repository.Task{}, fmt.Errorf("clear task time block: %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 { + 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 new file mode 100644 index 0000000..56951ea --- /dev/null +++ b/server/internal/service/task_list.go @@ -0,0 +1,194 @@ +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 + tasks *repository.TaskRepository +} + +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. +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. 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 { + return fmt.Errorf("get task list: %w", err) + } + if list.IsDefault { + return ErrCannotDeleteDefaultTaskList + } + + 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 + } + return txLists.Delete(ctx, id, userID, workspaceID) + }) + if 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/App.tsx b/src/App.tsx index c48503b..8b265b1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,9 @@ import { AcceptWorkspaceInvitePage } from "./auth/AcceptWorkspaceInvitePage"; import { LoginPage } from "./auth/LoginPage"; import { RegisterPage } from "./auth/RegisterPage"; import { ProtectedRoute } from "./auth/ProtectedRoute"; +import { PublicBookingPage } from "./booking/PublicBookingPage"; +import { PublicIndexPage } from "./booking/PublicIndexPage"; +import { CancelBookingPage } from "./booking/CancelBookingPage"; import { SettingsModal } from "./settings/SettingsModal"; import { getSettingsSections } from "./settings/settingsSections"; import { Toaster } from "./components/ui/Toaster"; @@ -24,6 +27,25 @@ function App() { <Route path="/login" element={<LoginPage />} /> <Route path="/register" element={<RegisterPage />} /> <Route path="/accept-workspace-invite" element={<AcceptWorkspaceInvitePage />} /> + {/* The signed cancel link's own destination (#327, ADR-0084, + ADR-0087): a static segment, so it ranks above the dynamic + /:handle route below and can never be shadowed by a claimed + Handle — also refused outright at claim time (ReservedHandles). */} + <Route path="/cancel-booking" element={<CancelBookingPage />} /> + {/* The public Booking Link page (#324, ADR-0084, ADR-0087): a + stranger with no Session opens /:handle/:slug — sibling to the + routes above, deliberately outside ProtectedRoute below. A + claimed Handle can never collide with a literal route (the + reserved-word registry the backend enforces at claim time), and + React Router itself ranks a static segment above a dynamic one at + the same depth, so "/settings/account" et al. are never shadowed. */} + <Route path="/:handle/:slug" element={<PublicBookingPage />} /> + {/* The public index page (#325, ADR-0084, ADR-0087): the derived + rendering of an owner's Public Booking Links at /:handle, one + path segment shallower than the route above — React Router + ranks it as a distinct, less-specific match, so the two never + shadow each other. */} + <Route path="/:handle" element={<PublicIndexPage />} /> <Route path="/" element={ diff --git a/src/booking/CancelBookingPage.test.tsx b/src/booking/CancelBookingPage.test.tsx new file mode 100644 index 0000000..39bc800 --- /dev/null +++ b/src/booking/CancelBookingPage.test.tsx @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { ApiError } from "../lib/apiClient"; + +vi.mock("../lib/publicBookingApi", async () => { + const actual = await vi.importActual<typeof import("../lib/publicBookingApi")>("../lib/publicBookingApi"); + return { + ...actual, + publicBookingApi: { cancel: vi.fn() }, + }; +}); + +const { publicBookingApi } = await import("../lib/publicBookingApi"); +const { CancelBookingPage } = await import("./CancelBookingPage"); + +function renderPage(path = "/cancel-booking?token=good-token") { + return render( + <MemoryRouter initialEntries={[path]}> + <Routes> + <Route path="/cancel-booking" element={<CancelBookingPage />} /> + </Routes> + </MemoryRouter>, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("CancelBookingPage", () => { + it("cancels the booking once the visitor confirms", async () => { + vi.mocked(publicBookingApi.cancel).mockResolvedValue(undefined); + const user = userEvent.setup(); + + renderPage(); + await user.click(screen.getByRole("button", { name: "Cancel booking" })); + + expect(publicBookingApi.cancel).toHaveBeenCalledWith("good-token"); + expect(await screen.findByText("Booking cancelled")).toBeInTheDocument(); + }); + + it("does not cancel just from opening the page", () => { + renderPage(); + + expect(screen.getByText("Cancel this booking?")).toBeInTheDocument(); + expect(publicBookingApi.cancel).not.toHaveBeenCalled(); + }); + + it("shows a distinct message when the booking already started", async () => { + vi.mocked(publicBookingApi.cancel).mockRejectedValue(new ApiError(409, "booking_already_started", "too late")); + const user = userEvent.setup(); + + renderPage(); + await user.click(screen.getByRole("button", { name: "Cancel booking" })); + + expect(await screen.findByText("This booking has already started")).toBeInTheDocument(); + }); + + it("shows a distinct message for an invalid or tampered token", async () => { + vi.mocked(publicBookingApi.cancel).mockRejectedValue(new ApiError(400, "invalid_request", "bad token")); + const user = userEvent.setup(); + + renderPage(); + await user.click(screen.getByRole("button", { name: "Cancel booking" })); + + expect(await screen.findByText("This cancel link isn't valid")).toBeInTheDocument(); + }); + + it("treats cancelling twice as fine — the second visit still shows success", async () => { + vi.mocked(publicBookingApi.cancel).mockResolvedValue(undefined); + const user = userEvent.setup(); + + renderPage(); + await user.click(screen.getByRole("button", { name: "Cancel booking" })); + expect(await screen.findByText("Booking cancelled")).toBeInTheDocument(); + }); + + it("shows the invalid-link message when the page opens with no token at all", () => { + renderPage("/cancel-booking"); + + expect(screen.getByText("This cancel link isn't valid")).toBeInTheDocument(); + }); +}); diff --git a/src/booking/CancelBookingPage.tsx b/src/booking/CancelBookingPage.tsx new file mode 100644 index 0000000..9f37a5c --- /dev/null +++ b/src/booking/CancelBookingPage.tsx @@ -0,0 +1,96 @@ +import { useState } from "react"; +import type { ReactNode } from "react"; +import { useSearchParams } from "react-router"; +import { Button } from "../components/ui/Button"; +import { publicBookingApi, isBookingAlreadyStartedError, isInvalidCancelTokenError } from "../lib/publicBookingApi"; + +// The signed cancel link's own destination (#327, ADR-0087): a stranger with +// no account and no Session follows a link from their booking confirmation +// mail to /cancel-booking?token=..., routed in App.tsx outside +// ProtectedRoute, sibling to the public Booking Link page. Cancelling is a +// deliberate click, never the bare GET that opened this page — a mail +// client or link scanner prefetching the page can't trigger it. + +type Status = "confirming" | "cancelling" | "cancelled" | "already-started" | "invalid" | "error"; + +export function CancelBookingPage() { + const [searchParams] = useSearchParams(); + const token = searchParams.get("token") ?? ""; + const [status, setStatus] = useState<Status>(token ? "confirming" : "invalid"); + + async function handleCancel() { + setStatus("cancelling"); + try { + await publicBookingApi.cancel(token); + setStatus("cancelled"); + } catch (error) { + if (isBookingAlreadyStartedError(error)) { + setStatus("already-started"); + } else if (isInvalidCancelTokenError(error)) { + setStatus("invalid"); + } else { + setStatus("error"); + } + } + } + + if (status === "cancelled") { + return ( + <PageShell> + <h1 className="text-heading-lg font-medium text-ink">Booking cancelled</h1> + <p className="mt-2 text-body text-ink-muted"> + The time has been freed up. The host has been notified. + </p> + </PageShell> + ); + } + + if (status === "already-started") { + return ( + <PageShell> + <h1 className="text-heading-lg font-medium text-ink">This booking has already started</h1> + <p className="mt-2 text-body text-ink-muted">It's too late to cancel this one.</p> + </PageShell> + ); + } + + if (status === "invalid") { + return ( + <PageShell> + <h1 className="text-heading-lg font-medium text-ink">This cancel link isn't valid</h1> + <p className="mt-2 text-body text-ink-muted"> + It may have been mistyped, or the booking it points to is already gone. + </p> + </PageShell> + ); + } + + if (status === "error") { + return ( + <PageShell> + <h1 className="text-heading-lg font-medium text-ink">Something went wrong</h1> + <p className="mt-2 text-body text-ink-muted">Please try again in a moment.</p> + </PageShell> + ); + } + + return ( + <PageShell> + <h1 className="text-heading-lg font-medium text-ink">Cancel this booking?</h1> + <p className="mt-2 text-body text-ink-muted"> + This frees up the time immediately and lets the host know. + </p> + <Button className="mt-6" variant="filled" disabled={status === "cancelling"} onClick={handleCancel}> + {status === "cancelling" ? "Cancelling…" : "Cancel booking"} + </Button> + </PageShell> + ); +} + +function PageShell({ children }: { children: ReactNode }) { + return ( + <div className="min-h-screen bg-surface px-4 py-10 sm:px-8"> + <div className="mx-auto max-w-3xl">{children}</div> + </div> + ); +} diff --git a/src/booking/PublicBookingPage.test.tsx b/src/booking/PublicBookingPage.test.tsx new file mode 100644 index 0000000..8373915 --- /dev/null +++ b/src/booking/PublicBookingPage.test.tsx @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { ApiError } from "../lib/apiClient"; +import type { PublicBookingLink } from "../lib/publicBookingApi"; + +vi.mock("../lib/publicBookingApi", async () => { + const actual = await vi.importActual<typeof import("../lib/publicBookingApi")>("../lib/publicBookingApi"); + return { + ...actual, + publicBookingApi: { get: vi.fn(), slots: vi.fn(), book: vi.fn() }, + }; +}); + +const { publicBookingApi } = await import("../lib/publicBookingApi"); +const { PublicBookingPage } = await import("./PublicBookingPage"); + +const link: PublicBookingLink = { + hostName: "Damir", + hostTimezone: "Europe/Sarajevo", + title: "Intro call", + durationMinutes: 30, + location: "Google Meet", + description: "A quick chat.", + bookingHorizonDays: 60, + paused: false, +}; + +function renderPage(path = "/damir/intro-call") { + return render( + <MemoryRouter initialEntries={[path]}> + <Routes> + <Route path="/:handle/:slug" element={<PublicBookingPage />} /> + </Routes> + </MemoryRouter>, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("PublicBookingPage — resolving the link", () => { + it("renders the host and offer details once the link loads", async () => { + vi.mocked(publicBookingApi.get).mockResolvedValue(link); + vi.mocked(publicBookingApi.slots).mockResolvedValue([]); + + renderPage(); + + expect(await screen.findByText("Intro call")).toBeInTheDocument(); + expect(screen.getByText(/Damir/)).toBeInTheDocument(); + expect(screen.getByText(/30 min/)).toBeInTheDocument(); + expect(screen.getByText(/Google Meet/)).toBeInTheDocument(); + expect(screen.getByText("A quick chat.")).toBeInTheDocument(); + expect(screen.getByText(/Host timezone:/)).toHaveTextContent("Europe/Sarajevo"); + expect(publicBookingApi.get).toHaveBeenCalledWith("damir", "intro-call"); + }); + + it("shows a not-found message for an unknown handle or slug (404)", async () => { + vi.mocked(publicBookingApi.get).mockRejectedValue(new ApiError(404, "not_found", "booking link not found")); + + renderPage(); + + expect(await screen.findByText("This page doesn't exist.")).toBeInTheDocument(); + }); + + it("shows a generic error for anything else", async () => { + vi.mocked(publicBookingApi.get).mockRejectedValue(new Error("network down")); + + renderPage(); + + expect(await screen.findByText("Something went wrong.")).toBeInTheDocument(); + }); +}); + +describe("PublicBookingPage — Paused", () => { + it("says plainly that a Paused link isn't accepting bookings, without fetching slots", async () => { + vi.mocked(publicBookingApi.get).mockResolvedValue({ ...link, paused: true }); + + renderPage(); + + expect(await screen.findByRole("status")).toHaveTextContent("isn't accepting bookings right now"); + expect(publicBookingApi.slots).not.toHaveBeenCalled(); + }); +}); + +// The page's default visible month is always "today's month" — so a slot +// fixture usable across any test run has to land on today's own date rather +// than a hardcoded one. Noon UTC keeps the same calendar date in every real +// IANA zone this app or a CI runner is plausibly configured in. +function todayAsSlot(): Date { + const now = new Date(); + return new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0)); +} + +describe("PublicBookingPage — month grid and slots", () => { + it("marks a day with slots available, and selecting a slot only highlights it and reveals the form (nothing is submitted yet)", async () => { + vi.mocked(publicBookingApi.get).mockResolvedValue(link); + const slot = todayAsSlot(); + vi.mocked(publicBookingApi.slots).mockResolvedValue([slot]); + + renderPage(); + await screen.findByText("Intro call"); + await waitFor(() => expect(publicBookingApi.slots).toHaveBeenCalled()); + + const dayCell = await screen.findByRole("gridcell", { name: String(slot.getUTCDate()) }); + const dayButton = within(dayCell).getByRole("button"); + expect(dayButton).not.toBeDisabled(); + + const user = userEvent.setup(); + await user.click(dayButton); + + const slotButton = await screen.findByRole("button", { name: /^\d{1,2}(:\d{2})?\s?(AM|PM)?$/i }); + expect(slotButton).toHaveAttribute("aria-pressed", "false"); + + await user.click(slotButton); + expect(slotButton).toHaveAttribute("aria-pressed", "true"); + // Selecting a slot reveals the booking form but submits nothing on its + // own (#326) — only "Confirm booking" calls book(). + expect(publicBookingApi.get).toHaveBeenCalledTimes(1); + expect(publicBookingApi.book).not.toHaveBeenCalled(); + expect(screen.getByLabelText("Name")).toBeInTheDocument(); + expect(screen.getByLabelText("Email")).toBeInTheDocument(); + }); + + it("disables a day with no slots at all", async () => { + vi.mocked(publicBookingApi.get).mockResolvedValue(link); + vi.mocked(publicBookingApi.slots).mockResolvedValue([]); + + renderPage(); + await screen.findByText("Intro call"); + await waitFor(() => expect(publicBookingApi.slots).toHaveBeenCalled()); + + const slot = todayAsSlot(); + const dayCell = await screen.findByRole("gridcell", { name: String(slot.getUTCDate()) }); + expect(within(dayCell).getByRole("button")).toBeDisabled(); + }); +}); + +describe("PublicBookingPage — the booking form (#326)", () => { + async function selectFirstSlot(user: ReturnType<typeof userEvent.setup>, slot: Date) { + await screen.findByText("Intro call"); + await waitFor(() => expect(publicBookingApi.slots).toHaveBeenCalled()); + const dayCell = await screen.findByRole("gridcell", { name: String(slot.getUTCDate()) }); + await user.click(within(dayCell).getByRole("button")); + const slotButton = await screen.findByRole("button", { name: /^\d{1,2}(:\d{2})?\s?(AM|PM)?$/i }); + await user.click(slotButton); + } + + it("confirms the booking and shows the booked time once submitted", async () => { + vi.mocked(publicBookingApi.get).mockResolvedValue(link); + const slot = todayAsSlot(); + vi.mocked(publicBookingApi.slots).mockResolvedValue([slot]); + vi.mocked(publicBookingApi.book).mockResolvedValue({ start: slot, end: new Date(slot.getTime() + 30 * 60_000) }); + + const user = userEvent.setup(); + renderPage(); + await selectFirstSlot(user, slot); + + await user.type(screen.getByLabelText("Name"), "Bob Visitor"); + await user.type(screen.getByLabelText("Email"), "bob@example.com"); + await user.click(screen.getByRole("button", { name: "Confirm booking" })); + + expect(publicBookingApi.book).toHaveBeenCalledWith("damir", "intro-call", slot, "Bob Visitor", "bob@example.com"); + expect(await screen.findByText("You're booked!")).toBeInTheDocument(); + expect(screen.getByText(/A confirmation has been sent to bob@example.com/)).toBeInTheDocument(); + }); + + it("shows a message and lets the visitor pick again when the slot was just taken", async () => { + vi.mocked(publicBookingApi.get).mockResolvedValue(link); + const slot = todayAsSlot(); + vi.mocked(publicBookingApi.slots).mockResolvedValueOnce([slot]).mockResolvedValue([]); + vi.mocked(publicBookingApi.book).mockRejectedValue(new ApiError(409, "slot_taken", "that time was just taken")); + + const user = userEvent.setup(); + renderPage(); + await selectFirstSlot(user, slot); + + await user.type(screen.getByLabelText("Name"), "Bob Visitor"); + await user.type(screen.getByLabelText("Email"), "bob@example.com"); + await user.click(screen.getByRole("button", { name: "Confirm booking" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("That time was just taken"); + // The form itself is gone once the slot is cleared — no stale selection + // left highlighted for a slot that no longer exists. + expect(screen.queryByLabelText("Name")).not.toBeInTheDocument(); + await waitFor(() => expect(publicBookingApi.slots).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/src/booking/PublicBookingPage.tsx b/src/booking/PublicBookingPage.tsx new file mode 100644 index 0000000..8452028 --- /dev/null +++ b/src/booking/PublicBookingPage.tsx @@ -0,0 +1,329 @@ +import { useEffect, useMemo, useState } from "react"; +import type { FormEvent, ReactNode } from "react"; +import { useParams } from "react-router"; +import { DayPicker } from "react-day-picker"; +import "react-day-picker/style.css"; +import { addDays, format, startOfMonth } from "date-fns"; +import { toZonedTime } from "date-fns-tz"; +import { Button } from "../components/ui/Button"; +import { Input } from "../components/ui/Input"; +import { Select } from "../components/ui/Select"; +import { ApiError } from "../lib/apiClient"; +import { + publicBookingApi, + isNotFoundError, + isSlotTakenError, + type BookingConfirmation, + type PublicBookingLink, +} from "../lib/publicBookingApi"; +import { detectBrowserTimeZone, listTimeZones } from "../lib/timezones"; +import { detectBrowserTimeFormat } from "../lib/timeFormat"; +import type { TimeFormat } from "../lib/authApi"; +import { daysWithSlots, formatSlotTime, slotsForDay, zonedDayKey } from "../lib/publicBookingSlots"; + +// The public Booking Link page (#324, #326, ADR-0084, ADR-0087): a stranger +// with no account and no Session opens /:handle/:slug (routed in App.tsx, +// outside ProtectedRoute) and sees the host's offer, a month grid of +// availability, and that day's slots in their own detected timezone. +// Picking a slot reveals the booking form (name + email, both required); +// confirming replaces the page with a confirmation screen showing the +// booked time in the visitor's own timezone. A slot taken out from under the +// visitor — raced by someone else, or gone stale — re-fetches slots and asks +// them to pick again (ADR-0087). + +const TIME_ZONE_OPTIONS = listTimeZones().map((tz) => ({ value: tz, label: tz })); +const TIME_FORMAT_OPTIONS: { value: TimeFormat; label: string }[] = [ + { value: "12h", label: "12-hour" }, + { value: "24h", label: "24-hour" }, +]; + +type LinkResult = + | { status: "ready"; link: PublicBookingLink } + | { status: "not-found" } + | { status: "error" }; + +interface SlotsResult { + year: number; + month: number; + slots: Date[]; +} + +export function PublicBookingPage() { + const { handle = "", slug = "" } = useParams<{ handle: string; slug: string }>(); + + // null means "still loading (handle, slug)" — derived rather than a + // separate written-from-the-effect status flag, the same "no fetch + // needed to know that" posture AcceptWorkspaceInvitePage takes toward its + // own preview fetch (https://react.dev/learn/you-might-not-need-an-effect). + const [linkResult, setLinkResult] = useState<LinkResult | null>(null); + + // Per-visit view state only (#324's AC): detected once on mount, + // correctable on the page, and persisted nowhere — a reload starts fresh. + const [viewerZone, setViewerZone] = useState(() => detectBrowserTimeZone()); + const [timeFmt, setTimeFmt] = useState<TimeFormat>(() => detectBrowserTimeFormat()); + + const [visibleMonth, setVisibleMonth] = useState(() => startOfMonth(new Date())); + const [selectedDay, setSelectedDay] = useState<Date | undefined>(undefined); + const [selectedSlot, setSelectedSlot] = useState<Date | null>(null); + + // Tagged by the (year, month) it answers, so a still-in-flight request for + // a newly-paged-to month is "loading" by comparison rather than by a + // second written flag. + const [slotsResult, setSlotsResult] = useState<SlotsResult | null>(null); + // Bumped whenever a slot turns out to be stale (isSlotTakenError below) to + // force the slots effect to re-fetch the same (year, month) it already + // has — none of that effect's other deps change on their own here. + const [slotsRefreshKey, setSlotsRefreshKey] = useState(0); + + // The booking form (#326): visible once a slot is selected, gone once + // confirmation is set. bookingError is the form's own inline message — + // distinct from linkResult's page-level error, since a failed booking + // leaves the rest of the page (and the visitor's typed name/email) intact. + const [visitorName, setVisitorName] = useState(""); + const [visitorEmail, setVisitorEmail] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [bookingError, setBookingError] = useState<string | null>(null); + const [confirmation, setConfirmation] = useState<BookingConfirmation | null>(null); + + useEffect(() => { + let cancelled = false; + publicBookingApi + .get(handle, slug) + .then((result) => { + if (!cancelled) setLinkResult({ status: "ready", link: result }); + }) + .catch((error: unknown) => { + if (!cancelled) setLinkResult(isNotFoundError(error) ? { status: "not-found" } : { status: "error" }); + }); + return () => { + cancelled = true; + }; + }, [handle, slug]); + + const year = visibleMonth.getFullYear(); + const month = visibleMonth.getMonth() + 1; + + useEffect(() => { + if (!linkResult || linkResult.status !== "ready" || linkResult.link.paused) return; + + let cancelled = false; + publicBookingApi + .slots(handle, slug, year, month) + .then((slots) => { + if (!cancelled) setSlotsResult({ year, month, slots }); + }) + .catch(() => { + if (!cancelled) setSlotsResult({ year, month, slots: [] }); + }); + return () => { + cancelled = true; + }; + }, [linkResult, handle, slug, year, month, slotsRefreshKey]); + + const slotsLoading = !slotsResult || slotsResult.year !== year || slotsResult.month !== month; + const slots = useMemo( + () => (slotsLoading ? [] : slotsResult.slots), + [slotsLoading, slotsResult], + ); + + // Zone conversion and day grouping live in publicBookingSlots.ts, not here + // (#324's AC) — this component only ever reads their result. + const availableDays = useMemo(() => daysWithSlots(slots, viewerZone), [slots, viewerZone]); + const daySlots = useMemo( + () => (selectedDay ? slotsForDay(slots, selectedDay, viewerZone) : []), + [slots, selectedDay, viewerZone], + ); + + if (!linkResult) { + return ( + <PageShell> + <p className="text-body text-ink-muted">Loading…</p> + </PageShell> + ); + } + + if (linkResult.status === "not-found") { + return ( + <PageShell> + <h1 className="text-heading font-medium text-ink">This page doesn't exist.</h1> + </PageShell> + ); + } + + if (linkResult.status === "error") { + return ( + <PageShell> + <h1 className="text-heading font-medium text-ink">Something went wrong.</h1> + <p className="mt-1 text-body text-ink-muted">Please try again in a moment.</p> + </PageShell> + ); + } + + const link = linkResult.link; + + async function handleBook(event: FormEvent) { + event.preventDefault(); + if (!selectedSlot || submitting) return; + + setSubmitting(true); + setBookingError(null); + try { + const result = await publicBookingApi.book(handle, slug, selectedSlot, visitorName, visitorEmail); + setConfirmation(result); + } catch (error) { + if (isSlotTakenError(error)) { + setBookingError("That time was just taken. Please pick another."); + setSelectedSlot(null); + setSlotsRefreshKey((key) => key + 1); + } else if (error instanceof ApiError) { + setBookingError(error.message); + } else { + setBookingError("Something went wrong. Please try again."); + } + } finally { + setSubmitting(false); + } + } + + if (confirmation) { + const zonedStart = toZonedTime(confirmation.start, viewerZone); + return ( + <PageShell> + <h1 className="text-heading-lg font-medium text-ink">You're booked!</h1> + <p className="mt-2 text-body text-ink"> + {link.title} with {link.hostName} + </p> + <p className="mt-1 text-body text-ink-muted"> + {format(zonedStart, "EEEE, MMMM d, yyyy")} · {formatSlotTime(confirmation.start, viewerZone, timeFmt)} + </p> + <p className="mt-4 text-label-sm text-ink-muted"> + A confirmation has been sent to {visitorEmail}. + </p> + </PageShell> + ); + } + + // Month paging bounded by the booking horizon (#324's AC): the DayPicker + // itself refuses navigation past either bound. + const startMonth = startOfMonth(new Date()); + const endMonth = startOfMonth(addDays(new Date(), link.bookingHorizonDays)); + + return ( + <PageShell> + <h1 className="text-heading-lg font-medium text-ink">{link.title}</h1> + <p className="mt-1 text-body text-ink-muted"> + {link.hostName} · {link.durationMinutes} min{link.location ? ` · ${link.location}` : ""} + </p> + {link.description && <p className="mt-3 text-body text-ink">{link.description}</p>} + <p className="mt-1 text-label-sm text-ink-muted">Host timezone: {link.hostTimezone}</p> + + {link.paused ? ( + <p className="mt-6 text-body text-ink" role="status"> + This link isn't accepting bookings right now. + </p> + ) : ( + <div className="mt-6 flex flex-col gap-6 sm:flex-row"> + <div className="sm:w-[22rem]"> + <DayPicker + mode="single" + month={visibleMonth} + onMonthChange={setVisibleMonth} + startMonth={startMonth} + endMonth={endMonth} + weekStartsOn={1} + selected={selectedDay} + onSelect={(day) => { + setSelectedDay(day); + setSelectedSlot(null); + }} + disabled={(date) => !availableDays.has(zonedDayKey(date, viewerZone))} + modifiers={{ available: (date) => availableDays.has(zonedDayKey(date, viewerZone)) }} + modifiersClassNames={{ available: "rdp-day-available" }} + /> + + <div className="mt-4 flex flex-wrap gap-3"> + <Select label="Timezone" value={viewerZone} onValueChange={setViewerZone} options={TIME_ZONE_OPTIONS} /> + <Select label="Time format" value={timeFmt} onValueChange={setTimeFmt} options={TIME_FORMAT_OPTIONS} /> + </div> + </div> + + <div className="flex-1"> + <h2 className="text-label-sm font-medium text-ink"> + {selectedDay ? format(selectedDay, "EEEE, MMMM d") : "Select a day"} + </h2> + + {!selectedDay ? ( + <p className="mt-2 text-label-sm text-ink-muted">Pick an available day to see its times.</p> + ) : slotsLoading ? ( + <p className="mt-2 text-label-sm text-ink-muted">Loading times…</p> + ) : daySlots.length === 0 ? ( + <p className="mt-2 text-label-sm text-ink-muted">No times available this day.</p> + ) : ( + <ul className="mt-2 flex flex-wrap gap-2"> + {daySlots.map((slot) => ( + <li key={slot.toISOString()}> + <Button + variant={selectedSlot?.getTime() === slot.getTime() ? "filled" : "outline"} + size="small" + aria-pressed={selectedSlot?.getTime() === slot.getTime()} + onClick={() => { + setSelectedSlot(slot); + setBookingError(null); + }} + > + {formatSlotTime(slot, viewerZone, timeFmt)} + </Button> + </li> + ))} + </ul> + )} + + {/* Rendered outside the form itself (below) so it survives a + slot-taken refusal clearing selectedSlot and unmounting the + form — the visitor still needs to see why their pick just + vanished. */} + {bookingError && ( + <p className="mt-4 text-label-sm text-danger" role="alert"> + {bookingError} + </p> + )} + + {selectedSlot && ( + <form className="mt-4 flex max-w-sm flex-col gap-3" onSubmit={handleBook}> + <h2 className="text-label-sm font-medium text-ink"> + Booking {formatSlotTime(selectedSlot, viewerZone, timeFmt)} on {format(toZonedTime(selectedSlot, viewerZone), "EEEE, MMMM d")} + </h2> + <Input + label="Name" + value={visitorName} + onChange={(e) => setVisitorName(e.target.value)} + required + disabled={submitting} + /> + <Input + label="Email" + type="email" + value={visitorEmail} + onChange={(e) => setVisitorEmail(e.target.value)} + required + disabled={submitting} + /> + <Button type="submit" variant="filled" disabled={submitting}> + {submitting ? "Booking…" : "Confirm booking"} + </Button> + </form> + )} + </div> + </div> + )} + </PageShell> + ); +} + +function PageShell({ children }: { children: ReactNode }) { + return ( + <div className="min-h-screen bg-surface px-4 py-10 sm:px-8"> + <div className="mx-auto max-w-3xl">{children}</div> + </div> + ); +} diff --git a/src/booking/PublicIndexPage.test.tsx b/src/booking/PublicIndexPage.test.tsx new file mode 100644 index 0000000..e4f4895 --- /dev/null +++ b/src/booking/PublicIndexPage.test.tsx @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router"; +import type { PublicIndex } from "../lib/publicIndexApi"; + +vi.mock("../lib/publicIndexApi", async () => { + const actual = await vi.importActual<typeof import("../lib/publicIndexApi")>("../lib/publicIndexApi"); + return { + ...actual, + publicIndexApi: { get: vi.fn() }, + }; +}); + +const { publicIndexApi } = await import("../lib/publicIndexApi"); +const { PublicIndexPage } = await import("./PublicIndexPage"); + +const index: PublicIndex = { + hostName: "Damir Hadzagic", + links: [ + { slug: "intro-call", title: "Intro call", durationMinutes: 30 }, + { slug: "deep-dive", title: "Deep dive", durationMinutes: 60 }, + ], +}; + +function renderPage(path = "/damir") { + return render( + <MemoryRouter initialEntries={[path]}> + <Routes> + <Route path="/:handle" element={<PublicIndexPage />} /> + </Routes> + </MemoryRouter>, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("PublicIndexPage", () => { + it("renders the owner's name, initials, and every Public link with its title and duration", async () => { + vi.mocked(publicIndexApi.get).mockResolvedValue(index); + + renderPage(); + + expect(await screen.findByText("Damir Hadzagic")).toBeInTheDocument(); + expect(screen.getByText("DH")).toBeInTheDocument(); + expect(screen.getByText("Intro call")).toBeInTheDocument(); + expect(screen.getByText("30 min")).toBeInTheDocument(); + expect(screen.getByText("Deep dive")).toBeInTheDocument(); + expect(screen.getByText("60 min")).toBeInTheDocument(); + expect(publicIndexApi.get).toHaveBeenCalledWith("damir"); + }); + + it("links each entry to /:handle/:slug", async () => { + vi.mocked(publicIndexApi.get).mockResolvedValue(index); + + renderPage(); + + expect(await screen.findByRole("link", { name: /Intro call/ })).toHaveAttribute( + "href", + "/damir/intro-call", + ); + }); + + // #325's AC: "A Handle with no Public links renders an empty index rather + // than an error" — no name is shown either way, since the backend never + // reveals one when links is empty (ADR-0084's enumeration guard). + it("renders an empty index without a name for a Handle with no Public links", async () => { + vi.mocked(publicIndexApi.get).mockResolvedValue({ hostName: "", links: [] }); + + renderPage(); + + expect(await screen.findByText("Nothing here yet.")).toBeInTheDocument(); + expect(screen.queryByText(index.hostName)).not.toBeInTheDocument(); + }); + + // #325's AC: "An unknown Handle returns the same response as a Handle + // with no Public links" — the page has no separate "not found" branch at + // all, unlike PublicBookingPage: both render the same empty state. + it("renders the same empty index for an unknown Handle", async () => { + vi.mocked(publicIndexApi.get).mockResolvedValue({ hostName: "", links: [] }); + + renderPage("/no-such-handle"); + + expect(await screen.findByText("Nothing here yet.")).toBeInTheDocument(); + }); + + it("shows a generic error when the request fails", async () => { + vi.mocked(publicIndexApi.get).mockRejectedValue(new Error("network down")); + + renderPage(); + + expect(await screen.findByText("Something went wrong.")).toBeInTheDocument(); + }); +}); diff --git a/src/booking/PublicIndexPage.tsx b/src/booking/PublicIndexPage.tsx new file mode 100644 index 0000000..a1c0979 --- /dev/null +++ b/src/booking/PublicIndexPage.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from "react"; +import type { ReactNode } from "react"; +import { Link, useParams } from "react-router"; +import { publicIndexApi, type PublicIndex } from "../lib/publicIndexApi"; + +// The public index page (#325, ADR-0084, ADR-0087): a stranger with no +// Session opens /:handle (routed in App.tsx, outside ProtectedRoute) and +// sees the owner's Name and initials plus every Public Booking Link they +// hold, unioned across every Workspace. There is no row behind this page — +// it renders exactly what GET /api/public/{handle} answers, computed in +// exactly the sense CONTEXT.md's Task bucket is. An unknown Handle and a +// Handle with no Public links render identically (an empty index, no name) +// so the namespace cannot enumerate Users. + +type IndexResult = { status: "ready"; index: PublicIndex } | { status: "error" }; + +export function PublicIndexPage() { + const { handle = "" } = useParams<{ handle: string }>(); + + const [result, setResult] = useState<IndexResult | null>(null); + + useEffect(() => { + let cancelled = false; + publicIndexApi + .get(handle) + .then((index) => { + if (!cancelled) setResult({ status: "ready", index }); + }) + .catch(() => { + if (!cancelled) setResult({ status: "error" }); + }); + return () => { + cancelled = true; + }; + }, [handle]); + + if (!result) { + return ( + <PageShell> + <p className="text-body text-ink-muted">Loading…</p> + </PageShell> + ); + } + + if (result.status === "error") { + return ( + <PageShell> + <h1 className="text-heading font-medium text-ink">Something went wrong.</h1> + <p className="mt-1 text-body text-ink-muted">Please try again in a moment.</p> + </PageShell> + ); + } + + const { index } = result; + + if (index.links.length === 0) { + return ( + <PageShell> + <h1 className="text-heading font-medium text-ink">Nothing here yet.</h1> + <p className="mt-1 text-body text-ink-muted">This page has no public booking links.</p> + </PageShell> + ); + } + + return ( + <PageShell> + <div className="flex items-center gap-3"> + <div + className="flex h-12 w-12 shrink-0 items-center justify-center rounded-shell-pill bg-accent-soft text-label-sm font-medium text-accent-ink" + aria-hidden="true" + > + {initials(index.hostName)} + </div> + <h1 className="text-heading-lg font-medium text-ink">{index.hostName}</h1> + </div> + + <ul className="mt-6 flex flex-col gap-3"> + {index.links.map((link) => ( + <li key={link.slug}> + <Link + to={`/${handle}/${link.slug}`} + className="block rounded-shell-md border border-border px-4 py-3 transition-colors hover:bg-surface-hover" + > + <p className="text-body font-medium text-ink">{link.title}</p> + <p className="text-label-sm text-ink-muted">{link.durationMinutes} min</p> + </Link> + </li> + ))} + </ul> + </PageShell> + ); +} + +// initials derives an avatar label from a display Name (ADR-0047: not +// unique, freeform) — the first letter of the first and last word, or the +// first two letters of a single-word Name. Presentational only; nothing +// here is stored or compared. +function initials(name: string): string { + const words = name.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return ""; + if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); + return (words[0][0] + words[words.length - 1][0]).toUpperCase(); +} + +function PageShell({ children }: { children: ReactNode }) { + return ( + <div className="min-h-screen bg-surface px-4 py-10 sm:px-8"> + <div className="mx-auto max-w-3xl">{children}</div> + </div> + ); +} diff --git a/src/calendar-grid/AllDayLane.tsx b/src/calendar-grid/AllDayLane.tsx index d5d9085..d2f41df 100644 --- a/src/calendar-grid/AllDayLane.tsx +++ b/src/calendar-grid/AllDayLane.tsx @@ -1,10 +1,15 @@ import { AttachmentIndicator } from "./AttachmentIndicator"; +import { TaskDeadlineChip } from "./TaskDeadlineChip"; import { WriteBackErrorIndicator } from "./WriteBackErrorIndicator"; import { canWriteCalendarEvents, getCalendarById } from "../lib/calendar"; import { getOccurrenceBlockStyle } from "../lib/calendarColors"; import { useCalendarsStore } from "../lib/calendarsStore"; import { occurrenceIntersectsDay } from "../lib/occurrenceSegments"; import { occurrenceKey, type Occurrence } from "../lib/occurrence"; +import { viewerZone } from "../lib/floatingTime"; +import { taskDeadlineFallsOnDay } from "../lib/taskScheduling"; +import { useTaskListsStore } from "../lib/taskListsStore"; +import type { Task } from "../lib/tasksApi"; interface AllDayLaneProps { daysToShow: Date[]; @@ -18,6 +23,16 @@ interface AllDayLaneProps { ) => void; draggingKey: string | null; dragHoverDateKey: string | null; + // deadlineTasks is every Deadline-only Task (placement precedence's + // `allDay` surface, #312, ADR-0083) that should be visible somewhere in + // this window — the caller (TimeGrid) has already applied "Show tasks on + // calendar" and "Show completed", so this lane only has to bucket them by + // day, the same way it already does for occurrences. + deadlineTasks: Task[]; + /** Dragging a Deadline-only chip onto the hourly grid (#314) — the drop + * table's all-day-chip row. */ + onTaskDragStart: (task: Task, clientX: number, clientY: number) => void; + draggingTaskId: number | null; } /** @@ -37,10 +52,15 @@ export function AllDayLane({ onOccurrenceDragStart, draggingKey, dragHoverDateKey, + deadlineTasks, + onTaskDragStart, + draggingTaskId, }: AllDayLaneProps) { const calendars = useCalendarsStore((state) => state.calendars); + const taskLists = useTaskListsStore((state) => state.taskLists); + const zone = viewerZone(); - if (occurrences.length === 0) return null; + if (occurrences.length === 0 && deadlineTasks.length === 0) return null; return ( <div className="flex border-t border-border"> @@ -98,6 +118,17 @@ export function AllDayLane({ </button> ); })} + {deadlineTasks + .filter((task) => task.due && taskDeadlineFallsOnDay(task.due, day, zone)) + .map((task) => ( + <TaskDeadlineChip + key={task.id} + task={task} + taskList={taskLists.find((list) => list.id === task.taskListId)} + onDragStart={onTaskDragStart} + isDragging={task.id === draggingTaskId} + /> + ))} </div> ); })} diff --git a/src/calendar-grid/CustomRecurrenceDialog.test.tsx b/src/calendar-grid/CustomRecurrenceDialog.test.tsx index 5294fd9..b11ebf6 100644 --- a/src/calendar-grid/CustomRecurrenceDialog.test.tsx +++ b/src/calendar-grid/CustomRecurrenceDialog.test.tsx @@ -23,6 +23,7 @@ const baseUser: User = { timeFormat: "24h", workingHoursStart: null, workingHoursEnd: null, + handle: null, }; function seedWeekStart(weekStart: number) { diff --git a/src/calendar-grid/DayColumn.tsx b/src/calendar-grid/DayColumn.tsx index 020cca3..abc1421 100644 --- a/src/calendar-grid/DayColumn.tsx +++ b/src/calendar-grid/DayColumn.tsx @@ -2,7 +2,10 @@ import { useEffect, useRef, useState } from "react"; import { isSameDay } from "date-fns"; import { layoutOverlappingEvents } from "../lib/layoutOverlappingEvents"; import { getDaySegments, type OccurrenceDaySegment } from "../lib/occurrenceSegments"; +import { getTaskTimeBlockDaySegments, type TaskTimeBlockSegment } from "../lib/taskTimeBlockSegments"; import { occurrenceKey, type Occurrence } from "../lib/occurrence"; +import type { Task } from "../lib/tasksApi"; +import { useTaskListsStore } from "../lib/taskListsStore"; import { useWorkingHours } from "../hooks/useWorkingHours"; import { CLICK_DISTANCE_THRESHOLD_PX, isDragGesture } from "../lib/pointerDrag"; import { @@ -15,6 +18,7 @@ import { import { columnLayoutToBox } from "../lib/eventBlockGeometry"; import type { CalendarBlockStyle } from "../lib/calendarColors"; import { EventBlock, type EventDragKind } from "./EventBlock"; +import { TaskBlock } from "./TaskBlock"; import { CurrentTimeLine } from "./CurrentTimeLine"; import { DraftBlockPreview } from "./DraftBlockPreview"; import { EventDragPreview } from "./EventDragPreview"; @@ -22,6 +26,22 @@ import { DragReadout } from "./DragReadout"; const DRAFT_PSEUDO_EVENT_ID = "__draft-preview__"; +/** + * One item the day column's overlap-layout pass can place — an Occurrence's + * day segment or a Task's Time block day segment (#313, ADR-0083: Time + * blocks join Events in overlap layout, ADR-0004, rather than drawing over + * them). `layoutOverlappingEvents` only needs `start`/`end`, so this union + * feeds it directly; the `kind` discriminant is this file's own, not + * something either segment type carries. + */ +type GridBlockItem = + | (OccurrenceDaySegment & { kind: "occurrence" }) + | (TaskTimeBlockSegment & { kind: "task" }); + +function isDraftItem(item: GridBlockItem): boolean { + return item.kind === "occurrence" && item.occurrence.event.id === DRAFT_PSEUDO_EVENT_ID; +} + export interface EventDragPreviewData { top: number; height: number; @@ -39,6 +59,12 @@ export interface EventDragPreviewData { interface DayColumnProps { day: Date; occurrences: Occurrence[]; + /** Every Task placed on the hourly grid (`taskPlacement(task) === "grid"`) + * that should be visible somewhere in the window — the caller (TimeGrid) + * has already applied "Show tasks on calendar" and "Show completed", the + * same division it makes for `deadlineTasks` (#312). This column filters + * to the ones whose block actually touches `day`. */ + tasks: Task[]; pixelsPerHour: number; now: Date; onDraftCreated: (day: Date, draft: DraftBlock) => void; @@ -52,11 +78,18 @@ interface DayColumnProps { draggingKey: string | null; eventDragPreview: EventDragPreviewData | null; isLastColumn: boolean; + /** Rescheduling or resizing a Task's own Time block (#314) — the same + * `onDragStart` shape `onOccurrenceDragStart` gives EventBlock, just keyed + * on the Task rather than the Occurrence. */ + onTaskDragStart: (task: Task, kind: EventDragKind, clientX: number, clientY: number) => void; + draggingTaskId: number | null; + taskDragPreview: EventDragPreviewData | null; } export function DayColumn({ day, occurrences, + tasks, pixelsPerHour, now, onDraftCreated, @@ -65,8 +98,13 @@ export function DayColumn({ draggingKey, eventDragPreview, isLastColumn, + onTaskDragStart, + draggingTaskId, + taskDragPreview, }: DayColumnProps) { const daySegments = getDaySegments(occurrences, day); + const taskSegments = getTaskTimeBlockDaySegments(tasks, day); + const taskLists = useTaskListsStore((state) => state.taskLists); const isToday = isSameDay(day, now); const workingHours = useWorkingHours(); @@ -95,8 +133,9 @@ export function DayColumn({ // shrink to make room for it instead of the preview overlapping them. A // create-drag never crosses midnight (bounded to this one day column), so // its segment is just its own unclipped bounds. - const draftSegment: OccurrenceDaySegment | null = draftBlock + const draftSegment: GridBlockItem | null = draftBlock ? { + kind: "occurrence", occurrence: { event: { id: DRAFT_PSEUDO_EVENT_ID, @@ -112,18 +151,28 @@ export function DayColumn({ end: draftBlock.end, } : null; - const layoutInput = draftSegment ? [...daySegments, draftSegment] : daySegments; + const occurrenceItems: GridBlockItem[] = daySegments.map((segment) => ({ + ...segment, + kind: "occurrence", + })); + const taskItems: GridBlockItem[] = taskSegments.map((segment) => ({ + ...segment, + kind: "task", + })); + const layoutInput = draftSegment + ? [...occurrenceItems, ...taskItems, draftSegment] + : [...occurrenceItems, ...taskItems]; const allLayouts = layoutOverlappingEvents(layoutInput); - const layouts = allLayouts.filter( - (layout) => - layout.occurrence.occurrence.event.id !== DRAFT_PSEUDO_EVENT_ID && - occurrenceKey(layout.occurrence.occurrence) !== draggingKey, - ); + const layouts = allLayouts.filter((layout) => { + const item = layout.occurrence; + if (isDraftItem(item)) return false; + if (item.kind === "occurrence" && occurrenceKey(item.occurrence) === draggingKey) return false; + if (item.kind === "task" && item.task.id === draggingTaskId) return false; + return true; + }); const draftLayout = draftBlock - ? allLayouts.find( - (layout) => layout.occurrence.occurrence.event.id === DRAFT_PSEUDO_EVENT_ID, - ) + ? allLayouts.find((layout) => isDraftItem(layout.occurrence)) : undefined; function offsetYFromEvent(clientY: number): number { @@ -166,6 +215,13 @@ export function DayColumn({ <div ref={columnRef} onMouseDown={handleMouseDown} + // Read by TaskRow's resolveGridDropTime (#313) to find which day + // column, and which offset within it, a Task panel row was dropped on + // — the same + // data-attribute-plus-elementFromPoint technique AllDayLane/MonthGrid + // already use for their own cross-cell drags, here reaching across + // into the Tasks panel's own DOM subtree instead of just this grid's. + data-grid-day-ms={day.getTime()} className="relative flex-1 border-l border-border select-none" style={{ height: pixelsPerHour * HOURS_IN_DAY }} > @@ -188,20 +244,38 @@ export function DayColumn({ style={{ top: hour * pixelsPerHour }} /> ))} - {layouts.map((layout) => ( - <EventBlock - key={occurrenceKey(layout.occurrence.occurrence)} - occurrence={layout.occurrence.occurrence} - segmentStart={layout.occurrence.start} - segmentEnd={layout.occurrence.end} - column={layout.column} - columnCount={layout.columnCount} - pixelsPerHour={pixelsPerHour} - now={now} - onOccurrenceClick={onOccurrenceClick} - onDragStart={onOccurrenceDragStart} - /> - ))} + {layouts.map((layout) => { + const item = layout.occurrence; + if (item.kind === "occurrence") { + return ( + <EventBlock + key={occurrenceKey(item.occurrence)} + occurrence={item.occurrence} + segmentStart={item.start} + segmentEnd={item.end} + column={layout.column} + columnCount={layout.columnCount} + pixelsPerHour={pixelsPerHour} + now={now} + onOccurrenceClick={onOccurrenceClick} + onDragStart={onOccurrenceDragStart} + /> + ); + } + return ( + <TaskBlock + key={`task-${item.task.id}`} + task={item.task} + taskList={taskLists.find((list) => list.id === item.task.taskListId)} + segmentStart={item.start} + segmentEnd={item.end} + column={layout.column} + columnCount={layout.columnCount} + pixelsPerHour={pixelsPerHour} + onDragStart={onTaskDragStart} + /> + ); + })} {isToday && <CurrentTimeLine now={now} pixelsPerHour={pixelsPerHour} />} {draftBlock && draftLayout && @@ -258,6 +332,30 @@ export function DayColumn({ isLastColumn={eventDragPreview.isLastColumn} /> )} + {taskDragPreview && ( + <EventDragPreview + top={taskDragPreview.top} + height={taskDragPreview.height} + left={taskDragPreview.left} + width={taskDragPreview.width} + title={taskDragPreview.title} + start={taskDragPreview.start} + end={taskDragPreview.end} + blockStyle={taskDragPreview.blockStyle} + /> + )} + {taskDragPreview?.showReadout && ( + <DragReadout + top={taskDragPreview.top} + height={taskDragPreview.height} + left={taskDragPreview.left} + width={taskDragPreview.width} + start={taskDragPreview.start} + end={taskDragPreview.end} + columnWidth={taskDragPreview.columnWidth} + isLastColumn={taskDragPreview.isLastColumn} + /> + )} </div> ); } diff --git a/src/calendar-grid/EventAttendeesSection.test.tsx b/src/calendar-grid/EventAttendeesSection.test.tsx index c24ab14..13e17bc 100644 --- a/src/calendar-grid/EventAttendeesSection.test.tsx +++ b/src/calendar-grid/EventAttendeesSection.test.tsx @@ -60,6 +60,7 @@ beforeEach(() => { timeFormat: "24h", workingHoursStart: null, workingHoursEnd: null, + handle: null, }, pendingEmail: null, }); diff --git a/src/calendar-grid/EventModal.test.tsx b/src/calendar-grid/EventModal.test.tsx index b782a97..54a13d7 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"); @@ -92,6 +93,7 @@ const user: User = { timeFormat: "24h", workingHoursStart: null, workingHoursEnd: null, + handle: null, }; const DAY = new Date(2026, 7, 3); @@ -115,7 +117,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", @@ -188,6 +192,71 @@ describe("EventModal — create", () => { }); }); +// #319, ADR-0086: a timed create defaults to Busy, an all-day one to Free — +// applied live as the form is filled in, until the User overrides it +// directly in More options. +describe("EventModal — Busy defaults on create (ADR-0086)", () => { + it("defaults a fresh timed create to Busy", async () => { + renderCreate(); + + await userEvent.type(screen.getByLabelText("Title"), "Standup"); + await userEvent.click(saveButton()); + + expect(eventsApi.create).toHaveBeenCalledWith( + "token-123", + expect.objectContaining({ allDay: false, busy: true }), + ); + }); + + it("defaults a fresh all-day create to Free", async () => { + renderCreate(); + + await userEvent.type(screen.getByLabelText("Title"), "Holiday"); + await userEvent.click(screen.getByRole("checkbox", { name: /All day/ })); + await userEvent.click(saveButton()); + + expect(eventsApi.create).toHaveBeenCalledWith( + "token-123", + expect.objectContaining({ allDay: true, busy: false }), + ); + }); + + it("flips the live default back to Busy when All day is unchecked again", async () => { + renderCreate(); + + await userEvent.type(screen.getByLabelText("Title"), "Holiday"); + await userEvent.click(screen.getByRole("checkbox", { name: /All day/ })); + await userEvent.click(screen.getByRole("checkbox", { name: /All day/ })); + await userEvent.click(saveButton()); + + expect(eventsApi.create).toHaveBeenCalledWith( + "token-123", + expect.objectContaining({ allDay: false, busy: true }), + ); + }); + + it("keeps an explicit Busy choice once the User touches it, even after All day is toggled again", async () => { + renderCreate(); + + await userEvent.type(screen.getByLabelText("Title"), "Holiday"); + // Checking All day live-syncs Busy to Free. + await userEvent.click(screen.getByRole("checkbox", { name: /All day/ })); + await userEvent.click(screen.getByRole("button", { name: "More options" })); + // The User overrides it back to Busy directly. + await userEvent.click(screen.getByRole("checkbox", { name: /Show as busy/ })); + // Toggling All day off and back on again must not re-derive Busy now + // that the User has touched it. + await userEvent.click(screen.getByRole("checkbox", { name: /All day/ })); + await userEvent.click(screen.getByRole("checkbox", { name: /All day/ })); + await userEvent.click(saveButton()); + + expect(eventsApi.create).toHaveBeenCalledWith( + "token-123", + expect.objectContaining({ allDay: true, busy: true }), + ); + }); +}); + describe("EventModal — creating a calendar from the empty state (#233)", () => { it("explains why Save is disabled while no Calendar is selected", async () => { seed([], []); @@ -297,6 +366,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(); @@ -527,6 +665,58 @@ describe("EventModal — an Event crossing midnight (#231)", () => { }); }); +// #319, ADR-0086 +describe("EventModal — editing Busy", () => { + it("preserves an existing Free Event's Busy on an unrelated edit", async () => { + const event = makeEvent({ busy: false }); + renderEdit(event); + + await userEvent.clear(screen.getByLabelText("Title")); + await userEvent.type(screen.getByLabelText("Title"), "Standup (renamed)"); + await userEvent.click(saveButton()); + + expect(eventsApi.update).toHaveBeenCalledWith( + "token-123", + "evt-1", + expect.objectContaining({ busy: false }), + ); + }); + + it("auto-expands More options when Busy departs from its all-day default (ADR-0056)", () => { + const event = makeEvent({ busy: false }); + renderEdit(event); + + expect( + screen.getByRole("checkbox", { name: /Show as busy/ }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "More options" }), + ).not.toBeInTheDocument(); + }); + + it("does not auto-expand a timed Event that's already Busy, its own default", () => { + renderEdit(makeEvent({ busy: true })); + + expect( + screen.queryByRole("checkbox", { name: /Show as busy/ }), + ).not.toBeInTheDocument(); + }); + + it("toggling Show as busy off and saving writes Free", async () => { + renderEdit(makeEvent({ busy: true })); + + await userEvent.click(screen.getByRole("button", { name: "More options" })); + await userEvent.click(screen.getByRole("checkbox", { name: /Show as busy/ })); + await userEvent.click(saveButton()); + + expect(eventsApi.update).toHaveBeenCalledWith( + "token-123", + "evt-1", + expect.objectContaining({ busy: false }), + ); + }); +}); + describe("EventModal — a read-only Event", () => { // A Viewer's own Reminders are the one write the Event still accepts // (#211, ADR-0064) — never an Event-field write, and never a series op. diff --git a/src/calendar-grid/EventModal.tsx b/src/calendar-grid/EventModal.tsx index 122d0f6..7e107e4 100644 --- a/src/calendar-grid/EventModal.tsx +++ b/src/calendar-grid/EventModal.tsx @@ -40,7 +40,11 @@ import { resolveMaster, type Occurrence, } from "../lib/occurrence"; -import { resolveColor, type EditScope } from "../lib/recurrenceScope"; +import { + defaultBusyForAllDay, + resolveColor, + type EditScope, +} from "../lib/recurrenceScope"; import type { MasterFieldChanges } from "../lib/seriesOperation"; import { planEventSave, @@ -71,6 +75,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 +118,8 @@ const CALENDAR_EMPTY_STATE_COPY: Record<CalendarPickerEmptyReason, string> = { "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 = @@ -132,6 +140,9 @@ interface InitialFormState { /** The stored rule when `repeat` is "custom"; undefined otherwise. */ customRule: string | undefined; allDay: boolean; + /** Whether this Event consumes the time it occupies (ADR-0086) — a timed + * Event defaults to Busy, an all-day one to Free. */ + busy: boolean; reminders: Reminder[]; description: string; location: string; @@ -190,6 +201,8 @@ function deriveInitialFormState( repeat, customRule: repeat === "custom" ? rrule : undefined, allDay: Boolean(event.allDay), + // Absent means Busy, matching RFC 5545's own default (ADR-0086). + busy: event.busy ?? true, reminders: event.reminders ?? [], description: event.description ?? "", location: event.location ?? "", @@ -208,6 +221,11 @@ function deriveInitialFormState( repeat: "none", customRule: undefined, allDay: false, + // A brand-new draft always opens timed (see `allDay: false` above), so + // its own default is Busy; the all-day-defaults-Free departure + // (ADR-0086) takes effect the moment the User checks "All day" — see + // handleAllDayChange below. + busy: defaultBusyForAllDay(false), reminders: [], description: "", location: "", @@ -271,6 +289,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 +321,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 +395,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 @@ -410,6 +445,9 @@ export function EventModal(props: EventModalProps) { reminderCount: initial.reminders.length, attachmentCount: master?.attachments?.length ?? 0, attendeeCount: master?.attendeeCount ?? 0, + // The all-day-based default is Busy for timed, Free for all-day + // (ADR-0086) — an Event that departs from it is worth surfacing. + busyDiffersFromDefault: initial.busy !== defaultBusyForAllDay(initial.allDay), }), ); @@ -473,6 +511,12 @@ export function EventModal(props: EventModalProps) { ? calendarId : ""; const [allDay, setAllDay] = useState(initial.allDay); + const [busy, setBusy] = useState(initial.busy); + // Whether the acting User has explicitly touched Busy in this create + // session — once true, toggling All day no longer overrides their choice. + // Never consulted in edit mode, where an existing Event's Busy is never + // silently flipped by retoggling All day (ADR-0086). + const busyTouchedRef = useRef(false); const [repeat, setRepeat] = useState<RepeatChoice>(initial.repeat); const [customRule, setCustomRule] = useState<string | undefined>( initial.customRule, @@ -539,6 +583,22 @@ export function EventModal(props: EventModalProps) { }, ]; + // A fresh create's own Busy follows All day live, until the User touches + // Busy directly in More options — the all-day-defaults-Free departure + // (ADR-0086) applied as the form is filled in, not just at open. An + // edit's existing Busy is never silently flipped by retoggling All day. + function handleAllDayChange(checked: boolean) { + setAllDay(checked); + if (mode === "create" && !busyTouchedRef.current) { + setBusy(defaultBusyForAllDay(checked)); + } + } + + function handleBusyChange(checked: boolean) { + busyTouchedRef.current = true; + setBusy(checked); + } + function handleRepeatChange(value: RepeatChoice) { // Selecting "Custom…" opens the dialog rather than committing immediately; // the choice is confirmed only when the dialog's Done button fires. @@ -732,6 +792,7 @@ export function EventModal(props: EventModalProps) { start: startForRule, end: allDay ? addDays(startOfDay(day), 1) : timeStringToDate(endDay, endTime), allDay, + busy, rrule: repeat === "custom" ? customRule : buildRule(repeat, startForRule), description: description.trim(), location: location.trim(), @@ -765,6 +826,7 @@ export function EventModal(props: EventModalProps) { start: props.occurrence.start, end: props.occurrence.end, allDay: initial.allDay, + busy: initial.busy, rrule: master?.rrule, description: initial.description, location: initial.location, @@ -1131,7 +1193,7 @@ export function EventModal(props: EventModalProps) { <label className="mt-4 inline-flex cursor-pointer items-center gap-2 text-label-sm text-ink"> <Checkbox checked={allDay} - onCheckedChange={setAllDay} + onCheckedChange={handleAllDayChange} aria-label="All day" disabled={isReadOnlyEvent} /> @@ -1226,7 +1288,12 @@ export function EventModal(props: EventModalProps) { <p className="text-label-sm text-ink-muted"> {CALENDAR_EMPTY_STATE_COPY[calendarEmptyReason]} </p> - {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" && ( <button type="button" onClick={() => setIsCreateCalendarOpen(true)} @@ -1323,6 +1390,16 @@ export function EventModal(props: EventModalProps) { {isExpanded && ( <> + <label className="mt-4 inline-flex cursor-pointer items-center gap-2 text-label-sm text-ink"> + <Checkbox + checked={busy} + onCheckedChange={handleBusyChange} + aria-label="Show as busy" + disabled={isReadOnlyEvent} + /> + Show as busy + </label> + <IconFieldRow icon={<MapPin className="size-4" />}> <Input aria-label="Location" diff --git a/src/calendar-grid/MonthDayCell.tsx b/src/calendar-grid/MonthDayCell.tsx index 4541596..1ae01b0 100644 --- a/src/calendar-grid/MonthDayCell.tsx +++ b/src/calendar-grid/MonthDayCell.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { format } from "date-fns"; import { AttachmentIndicator } from "./AttachmentIndicator"; +import { MonthTaskChip } from "./MonthTaskChip"; import { WriteBackErrorIndicator } from "./WriteBackErrorIndicator"; import { occurrenceKey, type Occurrence } from "../lib/occurrence"; import type { DraftBlock } from "../lib/gridTime"; @@ -8,6 +9,9 @@ import { canWriteCalendarEvents, getCalendarById } from "../lib/calendar"; import { getOccurrenceBlockStyle } from "../lib/calendarColors"; import { useCalendarsStore } from "../lib/calendarsStore"; import { useShellStore } from "../lib/shellStore"; +import { useTaskListsStore } from "../lib/taskListsStore"; +import { getTaskListById } from "../lib/taskListsApi"; +import type { Task } from "../lib/tasksApi"; import { computeCellDraft, computeChipCapacity } from "../lib/monthGrid"; import { useTimePattern } from "../hooks/useTimePattern"; @@ -20,6 +24,12 @@ interface MonthDayCellProps { isToday: boolean; isDragHover: boolean; occurrences: Occurrence[]; + /** Every Task placed on this date (#315, ADR-0083) — the caller has + * already applied placement precedence (Time block's date, else the + * Deadline's), "Show tasks on calendar" and "Show completed", the same + * division `TimeGrid` makes before handing `DayColumn`/`AllDayLane` their + * own day-scoped Task lists. */ + tasks: Task[]; onDraftCreated: (day: Date, draft: DraftBlock) => void; onOccurrenceClick: (occurrence: Occurrence) => void; onOccurrenceDragStart: ( @@ -27,19 +37,33 @@ interface MonthDayCellProps { clientX: number, clientY: number, ) => void; + onTaskDragStart: (task: Task, clientX: number, clientY: number) => void; } +// One cell's chips, Occurrences and Tasks merged into a single chronological +// list (#315) so a User planning a month sees both in the order they'd +// actually happen that day, rather than every Occurrence before every Task +// regardless of time. A Deadline-only Task (no Time block) sorts by its +// Deadline instant the same way an all-day Occurrence sorts by midnight — +// near the top, since both carry no real time of day. +type MonthChipItem = + | { kind: "occurrence"; occurrence: Occurrence; time: number } + | { kind: "task"; task: Task; time: number }; + export function MonthDayCell({ date, inCurrentMonth, isToday, isDragHover, occurrences, + tasks, onDraftCreated, onOccurrenceClick, onOccurrenceDragStart, + onTaskDragStart, }: MonthDayCellProps) { const calendars = useCalendarsStore((state) => state.calendars); + const taskLists = useTaskListsStore((state) => state.taskLists); const setSelectedDate = useShellStore((state) => state.setSelectedDate); const setActiveView = useShellStore((state) => state.setActiveView); const timePattern = useTimePattern(); @@ -67,13 +91,29 @@ export function MonthDayCell({ onDraftCreated(date, computeCellDraft(date, new Date())); } + const chipItems: MonthChipItem[] = [ + ...occurrences.map((occurrence) => ({ + kind: "occurrence" as const, + occurrence, + time: occurrence.start.getTime(), + })), + // A Task here is already guaranteed to carry a Time block or a Deadline + // (never neither) by placement precedence — the caller only ever hands + // this cell a Task that fell through `taskFallsOnMonthDay`. + ...tasks.map((task) => ({ + kind: "task" as const, + task, + time: (task.start ?? (task.due as Date)).getTime(), + })), + ].sort((a, b) => a.time - b.time); + const { visibleCount, overflowCount } = computeChipCapacity( - occurrences.length, + chipItems.length, availableHeight, CHIP_ROW_HEIGHT_PX, MORE_ROW_HEIGHT_PX, ); - const visibleOccurrences = occurrences.slice(0, visibleCount); + const visibleChipItems = chipItems.slice(0, visibleCount); return ( <div @@ -99,7 +139,20 @@ export function MonthDayCell({ ref={eventsContainerRef} className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-hidden" > - {visibleOccurrences.map((occurrence) => { + {visibleChipItems.map((item) => { + if (item.kind === "task") { + return ( + <MonthTaskChip + key={`task-${item.task.id}`} + task={item.task} + taskList={getTaskListById(taskLists, item.task.taskListId)} + timePattern={timePattern} + onDragStart={onTaskDragStart} + /> + ); + } + + const occurrence = item.occurrence; const calendar = getCalendarById( calendars, occurrence.event.calendarId, diff --git a/src/calendar-grid/MonthGrid.tsx b/src/calendar-grid/MonthGrid.tsx index 64706e0..5e3c41b 100644 --- a/src/calendar-grid/MonthGrid.tsx +++ b/src/calendar-grid/MonthGrid.tsx @@ -2,10 +2,18 @@ import { useRef, useState } from "react"; import { addDays, format, isSameDay, startOfDay } from "date-fns"; import { useShellStore } from "../lib/shellStore"; import { useCalendarsStore } from "../lib/calendarsStore"; +import { useTaskListsStore } from "../lib/taskListsStore"; +import { getTaskListById } from "../lib/taskListsApi"; +import { useTasksStore } from "../lib/tasksStore"; import { getCalendarById } from "../lib/calendar"; -import { getOccurrenceBlockStyle } from "../lib/calendarColors"; +import { getCalendarBlockStyle, getOccurrenceBlockStyle } from "../lib/calendarColors"; import { buildMonthGrid, getOccurrencesForDay } from "../lib/monthGrid"; import { computeMoveToDate } from "../lib/gridTime"; +import { viewerZone } from "../lib/floatingTime"; +import { taskDropIntent } from "../lib/taskDropIntent"; +import { taskFallsOnMonthDay, taskPlacement } from "../lib/taskScheduling"; +import { taskTimeBlockEnd } from "../lib/taskTimeBlockSegments"; +import type { Task } from "../lib/tasksApi"; import { useVisibleOccurrences } from "../hooks/useVisibleOccurrences"; import { useWeekStartsOn } from "../hooks/useWeekStartsOn"; import { usePointerDrag, type DragState } from "../hooks/usePointerDrag"; @@ -13,6 +21,7 @@ import { occurrenceKey, type Occurrence } from "../lib/occurrence"; import { MonthDayCell } from "./MonthDayCell"; import { MonthEventDragPreview } from "./MonthEventDragPreview"; import { ScopePicker } from "./ScopePicker"; +import { TaskDragPreview } from "./TaskDragPreview"; import { useOccurrenceDragCommit } from "./useOccurrenceDragCommit"; import type { DraftBlock } from "../lib/gridTime"; @@ -39,6 +48,15 @@ export function MonthGrid({ onDraftCreated, onOccurrenceClick }: MonthGridProps) const selectedDate = useShellStore((state) => state.selectedDate); const weekStartsOn = useWeekStartsOn(); const calendars = useCalendarsStore((state) => state.calendars); + const taskLists = useTaskListsStore((state) => state.taskLists); + const tasks = useTasksStore((state) => state.tasks); + const completedTasks = useTasksStore((state) => state.completedTasks); + const setTaskTimeBlock = useTasksStore((state) => state.setTaskTimeBlock); + const setTaskDeadlineAndClearTimeBlock = useTasksStore( + (state) => state.setTaskDeadlineAndClearTimeBlock, + ); + const showTasksOnCalendar = useShellStore((state) => state.showTasksOnCalendar); + const showCompletedTasks = useShellStore((state) => state.showCompletedTasks); const dragCommit = useOccurrenceDragCommit(); const [hoveredCellKey, setHoveredCellKey] = useState<string | null>(null); @@ -66,6 +84,25 @@ export function MonthGrid({ onDraftCreated, onOccurrenceClick }: MonthGridProps) windowEnd.getTime(), ); + // Month task chips (#315, ADR-0083): the same "Show tasks on calendar" / + // "Show completed" split TimeGrid already makes for its own gridTasks + // (Time-blocked) and deadlineTasks (Deadline-only) lists — a completed + // Time-blocked Task never disappears once "Show tasks on calendar" is on + // (only that switch hides it), where a completed Deadline-only one + // additionally needs "Show completed". Month draws both surfaces as one + // chip, so the two lists are merged once here rather than kept apart the + // way DayColumn/AllDayLane keep them. + const gridPlacedTasks = showTasksOnCalendar + ? [...tasks, ...completedTasks].filter((task) => taskPlacement(task) === "grid") + : []; + const deadlinePlacedTasks = showTasksOnCalendar + ? [...tasks, ...(showCompletedTasks ? completedTasks : [])].filter( + (task) => taskPlacement(task) === "allDay", + ) + : []; + const monthTasks = [...gridPlacedTasks, ...deadlinePlacedTasks]; + const zone = viewerZone(); + const now = new Date(); const eventDrag = usePointerDrag<Occurrence>({ @@ -106,6 +143,63 @@ export function MonthGrid({ onDraftCreated, onOccurrenceClick }: MonthGridProps) const dragBlockStyle = getOccurrenceBlockStyle(eventDrag.active?.event, dragCalendar); const draggingKey = eventDrag.active ? occurrenceKey(eventDrag.active) : null; + // taskDrag is Month's own drop-table row (#315, ADR-0083): a chip already + // shown here — whether it's a Time block or a Deadline-only one, there's + // no on-grid distinction between the two the way Day/Week's separate + // hourly-grid/all-day-lane surfaces draw — can only move to another Day + // cell, never resize (Month has no time axis to resize against). A + // Time-blocked Task keeps its time-of-day, combined with the drop day the + // same way a Month Event drag already does (`computeMoveToDate`); a + // Deadline-only one just takes the drop day outright. Both route through + // `taskDropIntent` so the write itself stays that pure module's call, not + // this handler's — the source's `kind` mirrors which chip is actually on + // screen: "timeBlock" for one with a Time block, "allDayChip" for one + // that's Deadline-only (there is no Month-grown panel-row source; that + // one only ever starts in `TaskRow`). + const taskDrag = usePointerDrag<Task>({ + onClick: () => {}, + onDrag: (task, state: DragState) => { + setHoveredCellKey(null); + suppressNextCellClickRef.current = true; + setTimeout(() => { + suppressNextCellClickRef.current = false; + }, 0); + + const targetDate = getCellDateAtPoint(cells, state.position.x, state.position.y); + if (!targetDate) return; + + if (task.start) { + const { start } = computeMoveToDate(task.start, taskTimeBlockEnd(task), targetDate); + const write = taskDropIntent( + { kind: "timeBlock", durationMinutes: task.durationMinutes as number }, + { surface: "monthDay", date: start }, + ); + if (write.action === "setTimeBlock") { + void setTaskTimeBlock(task.id, write.start, write.durationMinutes); + } + return; + } + + const write = taskDropIntent({ kind: "allDayChip" }, { surface: "monthDay", date: targetDate }); + if (write.action === "setDeadlineAndClearTimeBlock") { + void setTaskDeadlineAndClearTimeBlock(task.id, write.due); + } + }, + onMove: (_task, state: DragState) => { + const hoveredDate = getCellDateAtPoint(cells, state.position.x, state.position.y); + setHoveredCellKey(hoveredDate ? hoveredDate.toDateString() : null); + }, + }); + + function handleTaskDragStart(task: Task, clientX: number, clientY: number) { + taskDrag.start(task, clientX, clientY); + } + + const draggingTaskId = taskDrag.active ? taskDrag.active.id : null; + const draggingTaskList = taskDrag.active + ? getTaskListById(taskLists, taskDrag.active.taskListId) + : undefined; + return ( <div className="flex h-full flex-col"> <div className="grid grid-cols-7 border-t border-border"> @@ -129,9 +223,13 @@ export function MonthGrid({ onDraftCreated, onOccurrenceClick }: MonthGridProps) occurrences={getOccurrencesForDay(visibleOccurrences, date).filter( (occurrence) => occurrenceKey(occurrence) !== draggingKey, )} + tasks={monthTasks.filter( + (task) => task.id !== draggingTaskId && taskFallsOnMonthDay(task, date, zone), + )} onDraftCreated={handleDraftCreated} onOccurrenceClick={onOccurrenceClick} onOccurrenceDragStart={handleOccurrenceDragStart} + onTaskDragStart={handleTaskDragStart} /> ))} </div> @@ -144,6 +242,14 @@ export function MonthGrid({ onDraftCreated, onOccurrenceClick }: MonthGridProps) blockStyle={dragBlockStyle} /> )} + {taskDrag.active && ( + <TaskDragPreview + x={taskDrag.position.x} + y={taskDrag.position.y} + title={taskDrag.active.title} + blockStyle={getCalendarBlockStyle(draggingTaskList)} + /> + )} {dragCommit.isScopePickerOpen && ( <ScopePicker action="Edit" diff --git a/src/calendar-grid/MonthTaskChip.tsx b/src/calendar-grid/MonthTaskChip.tsx new file mode 100644 index 0000000..c1d1f0c --- /dev/null +++ b/src/calendar-grid/MonthTaskChip.tsx @@ -0,0 +1,59 @@ +import { format } from "date-fns"; +import { getCalendarBlockStyle } from "../lib/calendarColors"; +import type { TaskList } from "../lib/taskListsApi"; +import type { Task } from "../lib/tasksApi"; +import { useTasksStore } from "../lib/tasksStore"; +import { TaskCompletionControl } from "../components/layout/TaskCompletionControl"; + +interface MonthTaskChipProps { + task: Task; + taskList: TaskList | undefined; + timePattern: string; + onDragStart: (task: Task, clientX: number, clientY: number) => void; +} + +/** + * A Task's chip in a Month Day cell (#315, ADR-0083) — placement precedence + * collapsed to whichever single date the Task renders on there: the Time + * block's if it has one, else the Deadline's. Drawn in its Task List's + * colour (`getCalendarBlockStyle` needs only `.color`) and carrying the same + * circular completion control every other Task surface does — completing + * from here is the same `setTaskCompleted` write, not a parallel one. Shows + * its time only when it has a Time block, mirroring `MonthDayCell`'s own + * timed-vs-all-day Event chip convention, since a Deadline carries no + * meaningful time of day the way a Time block does. + */ +export function MonthTaskChip({ task, taskList, timePattern, onDragStart }: MonthTaskChipProps) { + const setTaskCompleted = useTasksStore((state) => state.setTaskCompleted); + const blockStyle = getCalendarBlockStyle(taskList); + + return ( + <div + onMouseDown={(domEvent) => { + domEvent.stopPropagation(); + onDragStart(task, domEvent.clientX, domEvent.clientY); + }} + style={blockStyle} + className="flex w-full cursor-pointer items-center gap-1 rounded-shell-sm px-1 text-left text-label-sm" + > + <span + // Stops a click on the control from bubbling into the chip's own + // drag gesture — the same guard every other Task surface's row uses + // for its identical two-gestures-in-one-chip shape. + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > + <TaskCompletionControl + checked={task.completed} + onCheckedChange={(checked) => { + void setTaskCompleted(task.id, checked); + }} + aria-label={task.completed ? `Mark "${task.title}" incomplete` : `Mark "${task.title}" complete`} + /> + </span> + <span className={`truncate ${task.completed ? "line-through opacity-70" : ""}`}> + {task.start ? `${format(task.start, timePattern)} ${task.title}` : task.title} + </span> + </div> + ); +} diff --git a/src/calendar-grid/TaskBlock.tsx b/src/calendar-grid/TaskBlock.tsx new file mode 100644 index 0000000..7ffd3a6 --- /dev/null +++ b/src/calendar-grid/TaskBlock.tsx @@ -0,0 +1,133 @@ +import { format } from "date-fns"; +import { getCalendarBlockStyle } from "../lib/calendarColors"; +import { columnLayoutToBox } from "../lib/eventBlockGeometry"; +import { durationToHeight, timeToY } from "../lib/gridTime"; +import { taskTimeBlockEnd } from "../lib/taskTimeBlockSegments"; +import type { TaskList } from "../lib/taskListsApi"; +import type { Task } from "../lib/tasksApi"; +import { useTasksStore } from "../lib/tasksStore"; +import { useTimePattern } from "../hooks/useTimePattern"; +import { TaskCompletionControl } from "../components/layout/TaskCompletionControl"; +import type { EventDragKind } from "./EventBlock"; +import { GRID_Z_OCCURRENCE_EDGE } from "./gridStacking"; + +interface TaskBlockProps { + task: Task; + taskList: TaskList | undefined; + /** The block's span clipped to the day this instance renders in — equal to + * the block's own bounds unless it crosses midnight (mirrors EventBlock's + * segmentStart/segmentEnd, issue #230). */ + segmentStart: Date; + segmentEnd: Date; + column: number; + columnCount: number; + pixelsPerHour: number; + onDragStart: (task: Task, kind: EventDragKind, clientX: number, clientY: number) => void; +} + +/** + * A Task's Time block on the hourly grid — placement precedence's `grid` + * surface (#313, ADR-0083). Joins Events in the same overlap-layout pass + * (ADR-0004) rather than drawing over them, drawn in its Task List's colour, + * and carries the same completion control the panel row and the all-day + * chip (`TaskDeadlineChip`) do — completing here is the same + * `setTaskCompleted` write, and the block stays in place rendered completed + * rather than being removed from under the cursor. Reschedules and resizes + * by the same gesture `EventBlock` uses — a plain `<div>` rather than + * `EventBlock`'s `<button>`, because this block already nests one + * interactive control (the completion toggle) and a button-in-button is + * invalid HTML (#314). Tasks carry no Access/read-only concept (ADR-0083), + * so unlike `EventBlock` there is no read-only branch here at all. + */ +export function TaskBlock({ + task, + taskList, + segmentStart, + segmentEnd, + column, + columnCount, + pixelsPerHour, + onDragStart, +}: TaskBlockProps) { + const setTaskCompleted = useTasksStore((state) => state.setTaskCompleted); + const blockStyle = getCalendarBlockStyle(taskList); + const timePattern = useTimePattern(); + const top = timeToY(segmentStart, pixelsPerHour); + const height = durationToHeight(segmentStart, segmentEnd, pixelsPerHour); + const { left, width } = columnLayoutToBox(column, columnCount); + const taskEnd = taskTimeBlockEnd(task); + + // A Task's block is never expected to cross midnight in practice, but the + // guard costs nothing and keeps this exactly in step with EventBlock's own + // rule: only the segment that actually carries the block's true start (or + // end) gets that edge's resize handle. + const showResizeStart = segmentStart.getTime() === (task.start as Date).getTime(); + const showResizeEnd = segmentEnd.getTime() === taskEnd.getTime(); + + function handleEdgeMouseDown(domEvent: React.MouseEvent, kind: "resize-start" | "resize-end") { + domEvent.stopPropagation(); + onDragStart(task, kind, domEvent.clientX, domEvent.clientY); + } + + return ( + <div + onMouseDown={(domEvent) => { + domEvent.stopPropagation(); + onDragStart(task, "move", domEvent.clientX, domEvent.clientY); + }} + style={{ + top: `${top}px`, + height: `${height}px`, + left: `${left}%`, + width: `calc(${width}% - 2px)`, + }} + className="absolute cursor-pointer overflow-hidden rounded-shell-sm" + > + {showResizeStart && ( + <div + onMouseDown={(domEvent) => handleEdgeMouseDown(domEvent, "resize-start")} + className="absolute inset-x-0 top-0 h-1.5 cursor-ns-resize" + style={{ zIndex: GRID_Z_OCCURRENCE_EDGE }} + /> + )} + <div style={blockStyle} className="h-full w-full px-1.5 py-1 text-left text-label-sm"> + <div className="flex items-center gap-1"> + <span + // Stops the completion control's own mousedown/click from bubbling + // into the block's own drag-to-move gesture — the same guard + // EventBlock's resize handles use to keep two gestures on one block + // from colliding. + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + className="shrink-0" + > + <TaskCompletionControl + checked={task.completed} + onCheckedChange={(checked) => { + void setTaskCompleted(task.id, checked); + }} + aria-label={ + task.completed ? `Mark "${task.title}" incomplete` : `Mark "${task.title}" complete` + } + /> + </span> + <p + className={`truncate font-medium ${task.completed ? "line-through opacity-70" : ""}`} + > + {task.title} + </p> + </div> + <p className="truncate opacity-90"> + {format(task.start as Date, timePattern)} – {format(taskEnd, timePattern)} + </p> + </div> + {showResizeEnd && ( + <div + onMouseDown={(domEvent) => handleEdgeMouseDown(domEvent, "resize-end")} + className="absolute inset-x-0 bottom-0 h-1.5 cursor-ns-resize" + style={{ zIndex: GRID_Z_OCCURRENCE_EDGE }} + /> + )} + </div> + ); +} diff --git a/src/calendar-grid/TaskDeadlineChip.tsx b/src/calendar-grid/TaskDeadlineChip.tsx new file mode 100644 index 0000000..71277c6 --- /dev/null +++ b/src/calendar-grid/TaskDeadlineChip.tsx @@ -0,0 +1,64 @@ +import { getCalendarBlockStyle } from "../lib/calendarColors"; +import type { TaskList } from "../lib/taskListsApi"; +import type { Task } from "../lib/tasksApi"; +import { useTasksStore } from "../lib/tasksStore"; +import { TaskCompletionControl } from "../components/layout/TaskCompletionControl"; + +interface TaskDeadlineChipProps { + task: Task; + taskList: TaskList | undefined; + onDragStart: (task: Task, clientX: number, clientY: number) => void; + /** Hidden rather than removed while its own drag is active — the same + * `invisible`-not-unmounted treatment AllDayLane already gives a dragging + * Occurrence chip, so this day's column doesn't shrink out from under the + * drop target mid-gesture. */ + isDragging: boolean; +} + +/** + * A Deadline-only Task's chip in the all-day lane (#312, ADR-0083) — the + * placement precedence's `allDay` surface. Drawn in its Task List's colour + * (`getCalendarBlockStyle` needs only `.color`, so it works unchanged for a + * Task List the same as it does for a Calendar), and carrying the same + * circular completion control the panel's own `TaskRow` does — completing + * from here is the same `setTaskCompleted` write, not a parallel one. Kept + * in place rather than removed once completed: "Show completed" (passed by + * the caller, which decides whether this chip is rendered at all) is what + * hides it, not completion itself. Draggable onto the hourly grid (#314, + * the drop table's all-day-chip row) — dragging within the all-day lane + * itself is not a gesture this ticket builds. + */ +export function TaskDeadlineChip({ task, taskList, onDragStart, isDragging }: TaskDeadlineChipProps) { + const setTaskCompleted = useTasksStore((state) => state.setTaskCompleted); + const blockStyle = getCalendarBlockStyle(taskList); + + return ( + <div + onMouseDown={(domEvent) => { + domEvent.stopPropagation(); + onDragStart(task, domEvent.clientX, domEvent.clientY); + }} + style={blockStyle} + className={`flex w-full cursor-pointer items-center gap-1 rounded-shell-sm px-1 text-left text-label-sm ${isDragging ? "invisible" : ""}`} + > + <span + // Stops a click on the control from bubbling into the chip's own + // drag gesture — the same guard TaskRow uses for its identical + // two-gestures-in-one-row shape. + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > + <TaskCompletionControl + checked={task.completed} + onCheckedChange={(checked) => { + void setTaskCompleted(task.id, checked); + }} + aria-label={task.completed ? `Mark "${task.title}" incomplete` : `Mark "${task.title}" complete`} + /> + </span> + <span className={`truncate ${task.completed ? "line-through opacity-70" : ""}`}> + {task.title} + </span> + </div> + ); +} diff --git a/src/calendar-grid/TaskDragPreview.tsx b/src/calendar-grid/TaskDragPreview.tsx new file mode 100644 index 0000000..ad568fc --- /dev/null +++ b/src/calendar-grid/TaskDragPreview.tsx @@ -0,0 +1,26 @@ +import type { CalendarBlockStyle } from "../lib/calendarColors"; + +interface TaskDragPreviewProps { + x: number; + y: number; + title: string; + blockStyle: CalendarBlockStyle; +} + +/** + * The floating label that follows the cursor while a Task panel row is + * being dragged toward the hourly grid (#313, ADR-0083) — the panel-drop + * gesture's own visual feedback, styled identically to + * `AllDayEventDragPreview` so a Task drag reads as the same kind of gesture + * an Event's own all-day drag already is. + */ +export function TaskDragPreview({ x, y, title, blockStyle }: TaskDragPreviewProps) { + return ( + <div + className="pointer-events-none fixed z-50 max-w-48 -translate-x-2 -translate-y-1/2 truncate rounded-shell-sm px-1.5 py-1 text-label-sm opacity-90 ring-2 ring-accent-ink" + style={{ left: x, top: y, ...blockStyle }} + > + {title} + </div> + ); +} diff --git a/src/calendar-grid/TimeGrid.tsx b/src/calendar-grid/TimeGrid.tsx index 2e64c74..27e4813 100644 --- a/src/calendar-grid/TimeGrid.tsx +++ b/src/calendar-grid/TimeGrid.tsx @@ -2,13 +2,23 @@ import { useEffect, useRef, useState } from "react"; import { addDays, format, isSameDay, startOfDay } from "date-fns"; import { useCalendarsStore } from "../lib/calendarsStore"; import { getCalendarById, type Calendar } from "../lib/calendar"; -import { getOccurrenceBlockStyle } from "../lib/calendarColors"; +import { getCalendarBlockStyle, getOccurrenceBlockStyle } from "../lib/calendarColors"; import { layoutOverlappingEvents } from "../lib/layoutOverlappingEvents"; import { columnLayoutToBox } from "../lib/eventBlockGeometry"; import { useVisibleOccurrences } from "../hooks/useVisibleOccurrences"; import { usePointerDrag, type DragState } from "../hooks/usePointerDrag"; import { CLICK_DISTANCE_THRESHOLD_PX, isDragGesture, type Point } from "../lib/pointerDrag"; import { occurrenceKey, type Occurrence } from "../lib/occurrence"; +import { viewerZone } from "../lib/floatingTime"; +import { useShellStore } from "../lib/shellStore"; +import { taskDeadlineFallsOnDay, taskPlacement } from "../lib/taskScheduling"; +import { taskDropIntent } from "../lib/taskDropIntent"; +import { getTaskTimeBlockDaySegments, taskTimeBlockEnd } from "../lib/taskTimeBlockSegments"; +import { useTaskListsStore } from "../lib/taskListsStore"; +import type { TaskList } from "../lib/taskListsApi"; +import type { Task } from "../lib/tasksApi"; +import { useTasksStore } from "../lib/tasksStore"; +import { resolveGridDropTime, isPointOverTasksPanel } from "./gridDropTargets"; import { PIXELS_PER_HOUR, computeMoveToDate, @@ -25,6 +35,7 @@ import { AllDayEventDragPreview } from "./AllDayEventDragPreview"; import { DayColumn, type EventDragPreviewData } from "./DayColumn"; import type { EventDragKind } from "./EventBlock"; import { ScopePicker } from "./ScopePicker"; +import { TaskDragPreview } from "./TaskDragPreview"; import { useOccurrenceDragCommit } from "./useOccurrenceDragCommit"; import { GRID_Z_HEADER } from "./gridStacking"; @@ -52,6 +63,28 @@ interface AllDayDragPayload { dragStartDay: Date; } +/** + * A Task's own grid drag (#314): either an existing Time block being + * rescheduled or resized, or a Deadline-only chip picked up off the all-day + * lane. The two sources resolve their drop differently (delta math for a + * block already on the grid; `resolveGridDropTime`'s elementFromPoint + * technique for a chip with no grid position to measure a delta from), so + * they stay distinct variants rather than one shape with optional fields. + */ +interface TaskBlockDragPayload { + source: "timeBlock"; + task: Task; + kind: EventDragKind; + columnWidth: number; +} + +interface TaskChipDragPayload { + source: "allDayChip"; + task: Task; +} + +type TaskGridDragPayload = TaskBlockDragPayload | TaskChipDragPayload; + function isLastDay(day: Date, daysToShow: Date[]): boolean { return isSameDay(day, daysToShow[daysToShow.length - 1]); } @@ -96,6 +129,34 @@ function computeDragTimes( ); } +/** + * `computeDragTimes`'s counterpart for a `timeBlock`-sourced Task drag + * (#314) — same move/resize branch, just reading the block's current + * `[start, end)` off the Task (`taskTimeBlockEnd`) instead of an + * Occurrence's own. Casts `task.start` away from `Date | null` here rather + * than at each of this function's own callers: every one only ever reaches + * it with a Task that has a Time block (a `TaskBlockDragPayload` only exists + * for one), so the cast belongs at the one place that math actually runs. + */ +function computeTaskDragTimes( + payload: TaskBlockDragPayload, + deltaX: number, + deltaY: number, +): DraftBlock { + const minuteOffset = (deltaY / PIXELS_PER_HOUR) * 60; + const originalStart = payload.task.start as Date; + const originalEnd = taskTimeBlockEnd(payload.task); + + if (payload.kind === "move") { + const dayOffset = + payload.columnWidth > 0 ? Math.round(deltaX / payload.columnWidth) : 0; + return computeMovedEventTimes(originalStart, originalEnd, dayOffset, minuteOffset); + } + + const edge = payload.kind === "resize-start" ? "start" : "end"; + return computeResizedEventTimes(originalStart, originalEnd, edge, minuteOffset); +} + interface EventDragPreview { day: Date; data: EventDragPreviewData; @@ -149,12 +210,73 @@ function computeEventDragPreview( }; } +/** + * `computeEventDragPreview`'s counterpart for a Task's Time block being + * rescheduled or resized on the grid (#314) — same math + * (`computeMovedEventTimes`/`computeResizedEventTimes`), same overlap-layout + * lookup for the ghost's column, just keyed on the Task rather than the + * Occurrence. Only meaningful for a `timeBlock`-sourced drag: an + * `allDayChip` drag has no existing grid position to preview a move from, + * and gets `TaskDragPreview`'s floating label instead, same as a panel row. + */ +function computeTaskDragPreview( + activeDrag: TaskBlockDragPayload, + dragDelta: Point, + gridTasks: Task[], + taskLists: TaskList[], + daysToShow: Date[], +): EventDragPreview { + const { start, end } = computeTaskDragTimes(activeDrag, dragDelta.x, dragDelta.y); + const originDay = startOfDay(activeDrag.task.start as Date); + const day = activeDrag.kind === "move" ? startOfDay(start) : originDay; + const isLastColumn = isLastDay(day, daysToShow); + const showReadout = isDragGesture(dragDelta, CLICK_DISTANCE_THRESHOLD_PX); + + const originDaySegments = getTaskTimeBlockDaySegments(gridTasks, originDay); + const originLayout = layoutOverlappingEvents(originDaySegments).find( + (layout) => layout.occurrence.task.id === activeDrag.task.id, + ); + const { left, width } = originLayout + ? columnLayoutToBox(originLayout.column, originLayout.columnCount) + : columnLayoutToBox(0, 1); + + const taskList = taskLists.find((list) => list.id === activeDrag.task.taskListId); + const blockStyle = getCalendarBlockStyle(taskList); + + return { + day, + data: { + top: timeToY(start, PIXELS_PER_HOUR), + height: durationToHeight(start, end, PIXELS_PER_HOUR), + left, + width, + title: activeDrag.task.title, + start, + end, + blockStyle, + columnWidth: activeDrag.columnWidth, + isLastColumn, + showReadout, + }, + }; +} + export function TimeGrid({ daysToShow, onDraftCreated, onOccurrenceClick, }: TimeGridProps) { const calendars = useCalendarsStore((state) => state.calendars); + const taskLists = useTaskListsStore((state) => state.taskLists); + const tasks = useTasksStore((state) => state.tasks); + const completedTasks = useTasksStore((state) => state.completedTasks); + const setTaskTimeBlock = useTasksStore((state) => state.setTaskTimeBlock); + const clearTaskTimeBlock = useTasksStore((state) => state.clearTaskTimeBlock); + const setTaskDeadlineAndClearTimeBlock = useTasksStore( + (state) => state.setTaskDeadlineAndClearTimeBlock, + ); + const showTasksOnCalendar = useShellStore((state) => state.showTasksOnCalendar); + const showCompletedTasks = useShellStore((state) => state.showCompletedTasks); const dragCommit = useOccurrenceDragCommit(); const scrollRef = useRef<HTMLDivElement>(null); const headerRef = useRef<HTMLDivElement>(null); @@ -177,6 +299,35 @@ export function TimeGrid({ const allDayOccurrences = allOccurrences.filter((occurrence) => occurrence.event.allDay); const visibleOccurrences = allOccurrences.filter((occurrence) => !occurrence.event.allDay); + // Deadline-only Tasks share the all-day lane with all-day Occurrences + // (#312, ADR-0083): "Show tasks on calendar" gates the feature outright, + // and "Show completed" (mirroring TasksPanel's own visibleTasks) decides + // whether a completed Task's chip is among them — completing one never + // removes it, only that switch does. + const zone = viewerZone(); + const candidateTasks = showTasksOnCalendar + ? [...tasks, ...(showCompletedTasks ? completedTasks : [])] + : []; + const deadlineTasks = candidateTasks.filter((task) => { + if (taskPlacement(task) !== "allDay" || !task.due) return false; + const due = task.due; + return daysToShow.some((day) => taskDeadlineFallsOnDay(due, day, zone)); + }); + + // Time-blocked Tasks render on the hourly grid — placement precedence's + // `grid` surface (#313, ADR-0083). Unlike `deadlineTasks`, this is never + // gated by "Show completed": the AC is that a completed block "stays in + // place ... rather than being removed from under the cursor", full stop — + // completing one must never make it vanish, which excluding it whenever + // "Show completed" happens to be off (the default) would do. "Show tasks + // on calendar" still gates the whole feature outright. DayColumn itself + // narrows this list to whichever day's block actually touches it, the same + // division it already makes for the full `visibleOccurrences` list handed + // to every column. + const gridTasks = showTasksOnCalendar + ? [...tasks, ...completedTasks].filter((task) => taskPlacement(task) === "grid") + : []; + useEffect(() => { const interval = setInterval( () => setNow(new Date()), @@ -270,6 +421,107 @@ export function TimeGrid({ allDayDragCalendar, ); + // taskDrag covers #314's remaining drop-table rows for a Task already on + // the grid or in the all-day lane: rescheduling a Time block, dragging a + // Deadline-only chip onto the hourly grid, and (outside the drop table + // proper) resizing a Time block. Every *drop-table* write goes through + // taskDropIntent — this handler only resolves *where* the pointer landed + // (by delta math for an in-grid move, by the same elementFromPoint + // technique TaskRow's panel-drop and the Event all-day drag both already + // use for every other surface), never what to write. Resizing is the one + // branch that bypasses it: it isn't a drop-table row (a resize's target is + // always the same surface it started on), so it writes directly, mirroring + // EventBlock's own resize. + const taskDrag = usePointerDrag<TaskGridDragPayload>({ + onClick: () => {}, + onDrag: (payload, state: DragState) => { + if (payload.source === "allDayChip") { + const dropTime = resolveGridDropTime(state.position.x, state.position.y); + if (!dropTime) return; + const write = taskDropIntent({ kind: "allDayChip" }, { surface: "hourlyGrid", time: dropTime }); + if (write.action === "setTimeBlock") { + void setTaskTimeBlock(payload.task.id, write.start, write.durationMinutes); + } + return; + } + + // Resizing never checks the drop point — exactly like EventBlock's own + // resize, it's pure delta math against the edge that moved. + if (payload.kind !== "move") { + const { start, end } = computeTaskDragTimes(payload, state.delta.x, state.delta.y); + void setTaskTimeBlock(payload.task.id, start, (end.getTime() - start.getTime()) / 60_000); + return; + } + + const allDayDate = getAllDayDateAtPoint(daysToShow, state.position.x, state.position.y); + if (allDayDate) { + const write = taskDropIntent( + { kind: "timeBlock", durationMinutes: payload.task.durationMinutes as number }, + { surface: "allDayLane", date: allDayDate }, + ); + if (write.action === "setDeadlineAndClearTimeBlock") { + void setTaskDeadlineAndClearTimeBlock(payload.task.id, write.due); + } + return; + } + + if (isPointOverTasksPanel(state.position.x, state.position.y)) { + const write = taskDropIntent( + { kind: "timeBlock", durationMinutes: payload.task.durationMinutes as number }, + { surface: "panel" }, + ); + if (write.action === "clearTimeBlock") void clearTaskTimeBlock(payload.task.id); + return; + } + + // Still on the hourly grid: an ordinary reschedule, resolved the same + // way an Event's own move is (delta math, not elementFromPoint), then + // routed through taskDropIntent so the write itself stays the pure + // module's call, not this handler's. + const movedStart = computeTaskDragTimes(payload, state.delta.x, state.delta.y).start; + const write = taskDropIntent( + { kind: "timeBlock", durationMinutes: payload.task.durationMinutes as number }, + { surface: "hourlyGrid", time: movedStart }, + ); + if (write.action === "setTimeBlock") { + void setTaskTimeBlock(payload.task.id, write.start, write.durationMinutes); + } + }, + }); + + function handleTaskDragStart( + task: Task, + kind: EventDragKind, + clientX: number, + clientY: number, + ) { + const columnWidth = + (daysContainerRef.current?.getBoundingClientRect().width ?? 0) / + daysToShow.length; + taskDrag.start({ source: "timeBlock", task, kind, columnWidth }, clientX, clientY); + } + + function handleTaskChipDragStart(task: Task, clientX: number, clientY: number) { + taskDrag.start({ source: "allDayChip", task }, clientX, clientY); + } + + // Safe to share between DayColumn (hides the source block of a "timeBlock" + // drag) and AllDayLane (hides the source chip of an "allDayChip" drag): + // whichever source is active, its Task only ever appears among one + // surface's own list (gridTasks or deadlineTasks), so the id never + // wrongly matches the other surface's item. + const draggingTaskId = taskDrag.active ? taskDrag.active.task.id : null; + + const taskDragPreview = + taskDrag.active?.source === "timeBlock" + ? computeTaskDragPreview(taskDrag.active, taskDrag.delta, gridTasks, taskLists, daysToShow) + : null; + + const draggingChipTaskList = + taskDrag.active?.source === "allDayChip" + ? taskLists.find((list) => list.id === taskDrag.active?.task.taskListId) + : undefined; + return ( <div className="flex h-full flex-col"> <div ref={scrollRef} className="isolate flex-1 overflow-y-auto"> @@ -299,6 +551,9 @@ export function TimeGrid({ allDayDrag.active ? occurrenceKey(allDayDrag.active.occurrence) : null } dragHoverDateKey={allDayHoverDateKey} + deadlineTasks={deadlineTasks} + onTaskDragStart={handleTaskChipDragStart} + draggingTaskId={draggingTaskId} /> </div> <div className="flex"> @@ -309,6 +564,7 @@ export function TimeGrid({ key={day.toISOString()} day={day} occurrences={visibleOccurrences} + tasks={gridTasks} pixelsPerHour={PIXELS_PER_HOUR} now={now} onDraftCreated={onDraftCreated} @@ -323,6 +579,13 @@ export function TimeGrid({ : null } isLastColumn={isLastDay(day, daysToShow)} + onTaskDragStart={handleTaskDragStart} + draggingTaskId={draggingTaskId} + taskDragPreview={ + taskDragPreview && isSameDay(taskDragPreview.day, day) + ? taskDragPreview.data + : null + } /> ))} </div> @@ -336,6 +599,14 @@ export function TimeGrid({ blockStyle={allDayDragBlockStyle} /> )} + {taskDrag.active?.source === "allDayChip" && ( + <TaskDragPreview + x={taskDrag.position.x} + y={taskDrag.position.y} + title={taskDrag.active.task.title} + blockStyle={getCalendarBlockStyle(draggingChipTaskList)} + /> + )} {dragCommit.isScopePickerOpen && ( <ScopePicker action="Edit" 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 ( <div className="h-full overflow-auto p-4"> diff --git a/src/calendar-grid/gridDropTargets.ts b/src/calendar-grid/gridDropTargets.ts new file mode 100644 index 0000000..c425b6d --- /dev/null +++ b/src/calendar-grid/gridDropTargets.ts @@ -0,0 +1,52 @@ +import { PIXELS_PER_HOUR, snapToIncrement, yToTime } from "../lib/gridTime"; + +/** + * Resolves a drag's drop point to a grid day and time, if it landed on one — + * the same data-attribute-plus-`elementFromPoint` technique + * `AllDayLane`/`MonthGrid` use for their own in-grid drags + * (`getAllDayDateAtPoint`, `getCellDateAtPoint`). Shared by every drag that + * can end on the hourly grid regardless of where it started: a Tasks panel + * row (#313), an existing grid block being rescheduled, and an all-day + * lane's Deadline-only chip (#314) all read the same `[data-grid-day-ms]` + * DayColumn sets. `null` when the drop landed anywhere else. + */ +export function resolveGridDropTime(clientX: number, clientY: number): Date | null { + const target = document.elementFromPoint(clientX, clientY); + const columnElement = target?.closest<HTMLElement>("[data-grid-day-ms]"); + const dayMsKey = columnElement?.dataset.gridDayMs; + if (!columnElement || !dayMsKey) return null; + + const day = new Date(Number(dayMsKey)); + const offsetY = clientY - columnElement.getBoundingClientRect().top; + return snapToIncrement(yToTime(offsetY, day, PIXELS_PER_HOUR), 15); +} + +/** + * Whether a drag's drop point landed over the Tasks panel — the drop + * table's "back to panel" row (#314), read off the same + * `elementFromPoint`-plus-data-attribute technique as `resolveGridDropTime`, + * against `data-tasks-panel` on the panel's own root (`TasksPanel.tsx`). + */ +export function isPointOverTasksPanel(clientX: number, clientY: number): boolean { + const target = document.elementFromPoint(clientX, clientY); + return target?.closest("[data-tasks-panel]") != null; +} + +/** + * Resolves a drag's drop point to a Month Day cell's date (#315), against + * `data-cell-date` — the same attribute `MonthGrid`'s own `getCellDateAtPoint` + * reads for an Event drag, here read from `TaskRow`'s panel-drop gesture, + * which (unlike a drag already on the grid) has no `cells` array of its own + * to match against. `date.toDateString()` round-trips through `new Date()` + * unambiguously, so no such array is needed. `null` when the drop landed + * anywhere else, including the hourly grid or the all-day lane, neither of + * which carries this attribute. + */ +export function resolveMonthDropDate(clientX: number, clientY: number): Date | null { + const target = document.elementFromPoint(clientX, clientY); + const cellElement = target?.closest<HTMLElement>("[data-cell-date]"); + const dateKey = cellElement?.dataset.cellDate; + if (!dateKey) return null; + + return new Date(dateKey); +} 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 ( + <label className="mt-4 flex items-start gap-2 text-label-sm text-ink"> + <Checkbox + checked={checked} + onCheckedChange={onCheckedChange} + aria-label={`Add to ${activeCalendarSet.name}`} + /> + <span>Add to {activeCalendarSet.name}</span> + </label> + ); +} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index ab230af..f0f459e 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -3,11 +3,17 @@ 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"; import { useEventsStore } from "../../lib/eventsStore"; -import { refetchCalendarsAndReconcile, useShellStore } from "../../lib/shellStore"; +import { + refetchCalendarsAndReconcile, + refetchTaskListsAndReconcile, + useShellStore, +} from "../../lib/shellStore"; +import { useTasksStore } from "../../lib/tasksStore"; import { useWorkspacesStore } from "../../lib/workspacesStore"; import { occurrenceKey, type Occurrence } from "../../lib/occurrence"; @@ -21,6 +27,8 @@ 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); + const fetchTasks = useTasksStore((state) => state.fetchTasks); useEffect(() => { fetchWorkspaces(); @@ -62,6 +70,15 @@ export function AppShell() { function refetch() { refetchCalendarsAndReconcile(); fetchEvents(); + // Fetched here rather than only inside TasksPanel's own effect: "Show + // tasks on calendar" (#312) renders a Deadline-only Task's chip in the + // all-day lane, and a Time-blocked Task's block on the hourly grid + // (#313), whether or not the panel is open, so the grid needs Tasks — + // and their Task Lists' colours, otherwise every chip/block would + // render in the unresolved fallback colour until the panel was opened + // at least once — in the store regardless. + fetchTasks(); + refetchTaskListsAndReconcile(); } if (activeWorkspaceId === null) return; @@ -73,7 +90,7 @@ export function AppShell() { document.addEventListener("visibilitychange", handleVisibilityChange); return () => document.removeEventListener("visibilitychange", handleVisibilityChange); - }, [fetchEvents, activeWorkspaceId]); + }, [fetchEvents, fetchTasks, activeWorkspaceId]); function handleCreateClick() { const draft = computeDefaultDraft(new Date()); @@ -99,6 +116,11 @@ export function AppShell() { } /> </main> + {tasksPanelOpen && ( + <aside className="w-80 shrink-0 border-l border-border bg-surface"> + <TasksPanel /> + </aside> + )} </div> {eventModalState?.mode === "create" && ( <EventModal diff --git a/src/components/layout/BookingLinkModal.tsx b/src/components/layout/BookingLinkModal.tsx new file mode 100644 index 0000000..4df4cb5 --- /dev/null +++ b/src/components/layout/BookingLinkModal.tsx @@ -0,0 +1,402 @@ +import { useEffect, useState } from "react"; +import { Dialog } from "@base-ui/react/dialog"; +import { Button } from "../ui/Button"; +import { buttonClasses } from "../ui/buttonClasses"; +import { Checkbox } from "../ui/Checkbox"; +import { Input } from "../ui/Input"; +import { Select } from "../ui/Select"; +import { Textarea } from "../ui/Textarea"; +import { useAsyncAction } from "../../hooks/useAsyncAction"; +import { useAuthStore } from "../../lib/authStore"; +import { authApi } from "../../lib/authApi"; +import { useAvailabilitySchedulesStore } from "../../lib/availabilitySchedulesStore"; +import { useBookingLinksStore } from "../../lib/bookingLinksStore"; +import { useCalendarsStore } from "../../lib/calendarsStore"; +import { canWriteCalendarEvents } from "../../lib/calendar"; +import { detectBrowserTimeZone } from "../../lib/timezones"; +import type { BookingLink, BookingLinkVisibility, BookingLinkWrite } from "../../lib/bookingLinksApi"; + +const DEFAULT_MINIMUM_NOTICE_MINUTES = 240; +const DEFAULT_BOOKING_HORIZON_DAYS = 60; + +const DURATION_PRESETS = ["15", "30", "45", "60"] as const; +type DurationChoice = (typeof DURATION_PRESETS)[number] | "custom"; + +const DURATION_OPTIONS: { value: DurationChoice; label: string }[] = [ + { value: "15", label: "15 minutes" }, + { value: "30", label: "30 minutes" }, + { value: "45", label: "45 minutes" }, + { value: "60", label: "60 minutes" }, + { value: "custom", label: "Custom" }, +]; + +const VISIBILITY_OPTIONS: { value: BookingLinkVisibility; label: string }[] = [ + { value: "public", label: "Public — listed and bookable" }, + { value: "private", label: "Private — unlisted, bookable by URL" }, + { value: "paused", label: "Paused — unlisted, refusing bookings" }, +]; + +function durationChoiceFor(minutes: number): DurationChoice { + return (DURATION_PRESETS as readonly string[]).includes(String(minutes)) ? (String(minutes) as DurationChoice) : "custom"; +} + +// slugify turns free text into a Slug candidate — lowercase, hyphens in +// place of anything else, no leading/trailing/doubled hyphen — mirroring +// the server's own slugPattern (booking_link_validation.go) closely enough +// that what this produces almost always validates as-is. +function slugify(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +type BookingLinkModalProps = + | { mode: "create"; onClose: () => void } + | { mode: "edit"; bookingLink: BookingLink; onClose: () => void }; + +// The Booking Link create/edit modal (#322, ADR-0084, ADR-0087): Title, +// Duration, Slug, Availability Schedule and Book-into Calendar on the face; +// Visibility, Location, Description, minimum notice, booking horizon and +// the Tasks half of the Conflict set behind More options (ADR-0056). A +// link saved without opening More options gets ADR-0087's own stated +// defaults. The Conflict set's Calendar half is edited with immediate +// effect from the edit modal only — there is no Booking Link id yet for a +// create-time toggle to attach to, so a create seeds it automatically +// (every owned Calendar plus Book-into) and it becomes editable the moment +// the link exists. +export function BookingLinkModal(props: BookingLinkModalProps) { + const { mode, onClose } = props; + const bookingLink = mode === "edit" ? props.bookingLink : undefined; + const isEditing = mode === "edit"; + + const createBookingLink = useBookingLinksStore((state) => state.createBookingLink); + const updateBookingLink = useBookingLinksStore((state) => state.updateBookingLink); + const addConflictCalendar = useBookingLinksStore((state) => state.addConflictCalendar); + const removeConflictCalendar = useBookingLinksStore((state) => state.removeConflictCalendar); + + const schedules = useAvailabilitySchedulesStore((state) => state.schedules); + const fetchSchedules = useAvailabilitySchedulesStore((state) => state.fetchSchedules); + // The Schedule picker needs at least the lazily-seeded "Default" Schedule + // (#320, ADR-0085) — fetched here rather than assumed already loaded, + // since this modal is reachable straight from the sidebar without ever + // visiting Settings first. + useEffect(() => { + fetchSchedules(detectBrowserTimeZone()).catch(() => {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const calendars = useCalendarsStore((state) => state.calendars); + const user = useAuthStore((state) => state.user); + const accessToken = useAuthStore((state) => state.accessToken); + + const [title, setTitle] = useState(bookingLink?.title ?? ""); + const [slug, setSlug] = useState(bookingLink?.slug ?? ""); + // Once the caller edits the Slug directly, Title no longer overwrites it + // — the same touched-tracking every prefill-from-another-field pattern in + // this app needs. + const [slugTouched, setSlugTouched] = useState(isEditing); + const [durationChoice, setDurationChoice] = useState<DurationChoice>( + durationChoiceFor(bookingLink?.durationMinutes ?? 30), + ); + const [customDuration, setCustomDuration] = useState( + durationChoiceFor(bookingLink?.durationMinutes ?? 30) === "custom" ? String(bookingLink?.durationMinutes) : "", + ); + const [scheduleId, setScheduleId] = useState<number | null>(bookingLink?.availabilityScheduleId ?? null); + const [bookIntoCalendarId, setBookIntoCalendarId] = useState<string | null>(bookingLink?.bookIntoCalendarId ?? null); + + const [visibility, setVisibility] = useState<BookingLinkVisibility>(bookingLink?.visibility ?? "public"); + const [location, setLocation] = useState(bookingLink?.location ?? ""); + const [description, setDescription] = useState(bookingLink?.description ?? ""); + const [minimumNoticeMinutes, setMinimumNoticeMinutes] = useState( + bookingLink?.minimumNoticeMinutes ?? DEFAULT_MINIMUM_NOTICE_MINUTES, + ); + const [bookingHorizonDays, setBookingHorizonDays] = useState( + bookingLink?.bookingHorizonDays ?? DEFAULT_BOOKING_HORIZON_DAYS, + ); + const [tasksInConflictSet, setTasksInConflictSet] = useState(bookingLink?.tasksInConflictSet ?? true); + + // ADR-0056's own rule: auto-expand only if editing something that already + // has a secondary field populated; create always starts collapsed. + const [isExpanded, setIsExpanded] = useState(() => + bookingLink + ? bookingLink.visibility !== "public" || + bookingLink.location !== "" || + bookingLink.description !== "" || + bookingLink.minimumNoticeMinutes !== DEFAULT_MINIMUM_NOTICE_MINUTES || + bookingLink.bookingHorizonDays !== DEFAULT_BOOKING_HORIZON_DAYS || + !bookingLink.tasksInConflictSet + : false, + ); + + // The URL preview needs a Handle even before one is claimed — fetched + // once, the same suggestion the create-first-link flow would go on to + // claim automatically (#321, ADR-0084). + const [handleSuggestion, setHandleSuggestion] = useState<string | null>(null); + useEffect(() => { + if (user?.handle || !accessToken) return; + authApi + .getHandleSuggestion(accessToken) + .then(setHandleSuggestion) + .catch(() => setHandleSuggestion("")); + // Runs once at mount; user?.handle and accessToken don't change over + // this modal's lifetime in a way that should refetch. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const { isSubmitting, error, run } = useAsyncAction(); + + const durationMinutes = durationChoice === "custom" ? Number(customDuration) : Number(durationChoice); + const canSubmit = + title.trim() !== "" && + slug.trim() !== "" && + Number.isFinite(durationMinutes) && + durationMinutes > 0 && + scheduleId !== null && + bookIntoCalendarId !== null; + + const writableCalendars = calendars.filter((c) => canWriteCalendarEvents(c)); + const handle = user?.handle ?? handleSuggestion ?? ""; + const previewUrl = `${window.location.origin}/${handle || "…"}/${slug || "…"}`; + + // A fresh create defaults Schedule and Book-into to the first option the + // moment each list has one, rather than leaving both Selects on an empty + // placeholder a User must resolve by hand before they can even glance at + // the rest of the face (#322: "a link created without opening More + // options is complete and usable" implies the face itself should already + // read as a usable default, not just what's behind More options). + if (!isEditing && scheduleId === null && schedules.length > 0) { + setScheduleId(schedules[0].id); + } + if (!isEditing && bookIntoCalendarId === null && writableCalendars.length > 0) { + setBookIntoCalendarId(writableCalendars[0].id); + } + + function handleTitleChange(value: string) { + setTitle(value); + if (!slugTouched) setSlug(slugify(value)); + } + + function handleSlugChange(value: string) { + setSlug(value); + setSlugTouched(true); + } + + async function handleSubmit(domEvent: React.FormEvent) { + domEvent.preventDefault(); + if (!canSubmit || scheduleId === null || bookIntoCalendarId === null) return; + + // Renaming (as opposed to a first publish) breaks every URL published + // under the old Slug, with nothing forwarding (ADR-0084) — only worth + // interrupting for when there's an old Slug to lose. + if (isEditing && bookingLink && slug !== bookingLink.slug) { + const proceed = window.confirm( + `Changing the slug breaks the URL published under "${bookingLink.slug}" — nothing forwards, and this cannot be undone.`, + ); + if (!proceed) return; + } + + const write: BookingLinkWrite = { + title: title.trim(), + slug: slug.trim(), + durationMinutes, + visibility, + availabilityScheduleId: scheduleId, + bookIntoCalendarId, + location, + description, + minimumNoticeMinutes, + bookingHorizonDays, + tasksInConflictSet, + }; + + await run(async () => { + if (isEditing && bookingLink) { + await updateBookingLink(bookingLink.id, write); + } else { + await createBookingLink(write); + } + onClose(); + }); + } + + // Immediate effect, no save step, matching CalendarSetMembershipDialog — + // only reachable once the link exists (edit mode), since create has no id + // yet to attach a Conflict set toggle to. + async function handleToggleConflictCalendar(calendarId: string, isMember: boolean) { + if (!bookingLink) return; + if (isMember) { + await removeConflictCalendar(bookingLink.id, calendarId).catch(() => {}); + } else { + await addConflictCalendar(bookingLink.id, calendarId).catch(() => {}); + } + } + + return ( + <Dialog.Root + open + onOpenChange={(open) => { + if (!open && !isSubmitting) onClose(); + }} + > + <Dialog.Portal> + <Dialog.Backdrop className="fixed inset-0 z-40 bg-ink/20" /> + <Dialog.Popup className="fixed top-1/2 left-1/2 z-50 max-h-[85vh] w-[32rem] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-shell-lg bg-surface p-5 shadow-elevation-3"> + <Dialog.Title className="text-heading font-medium text-ink"> + {isEditing ? `Edit ${bookingLink?.title}` : "New booking link"} + </Dialog.Title> + + <form onSubmit={handleSubmit} className="mt-4 flex flex-col gap-4"> + <Input label="Title" value={title} onChange={(e) => handleTitleChange(e.target.value)} /> + + <Select<DurationChoice> + label="Duration" + value={durationChoice} + onValueChange={setDurationChoice} + options={DURATION_OPTIONS} + /> + {durationChoice === "custom" && ( + <Input + label="Custom duration (minutes)" + type="number" + min={1} + value={customDuration} + onChange={(e) => setCustomDuration(e.target.value)} + /> + )} + + <Input label="Slug" value={slug} onChange={(e) => handleSlugChange(e.target.value)} /> + <p className="-mt-2 text-label-sm text-ink-muted">{previewUrl}</p> + + <Select<string> + label="Availability schedule" + value={scheduleId !== null ? String(scheduleId) : ""} + onValueChange={(value) => setScheduleId(Number(value))} + options={schedules.map((s) => ({ value: String(s.id), label: s.name }))} + placeholder="Choose a schedule" + /> + + <Select<string> + label="Book into" + value={bookIntoCalendarId ?? ""} + onValueChange={setBookIntoCalendarId} + options={writableCalendars.map((c) => ({ value: c.id, label: c.name }))} + placeholder="Choose a calendar" + /> + + <Button + type="button" + variant="ghost" + color="secondary" + size="small" + className="self-start" + onClick={() => setIsExpanded(true)} + disabled={isExpanded} + > + More options + </Button> + + {isExpanded && ( + <div className="flex flex-col gap-4 border-t border-border pt-4"> + <Select<BookingLinkVisibility> + label="Visibility" + value={visibility} + onValueChange={setVisibility} + options={VISIBILITY_OPTIONS} + /> + + <Input label="Location" value={location} onChange={(e) => setLocation(e.target.value)} /> + <Textarea + label="Description" + value={description} + onChange={(e) => setDescription(e.target.value)} + /> + + <div className="flex gap-3"> + <Input + label="Minimum notice (minutes)" + type="number" + min={0} + value={minimumNoticeMinutes} + onChange={(e) => setMinimumNoticeMinutes(Number(e.target.value))} + className="flex-1" + /> + <Input + label="Booking horizon (days)" + type="number" + min={1} + value={bookingHorizonDays} + onChange={(e) => setBookingHorizonDays(Number(e.target.value))} + className="flex-1" + /> + </div> + + <label className="flex items-center gap-2"> + <Checkbox + checked={tasksInConflictSet} + onCheckedChange={setTasksInConflictSet} + aria-label="Include my Tasks in the conflict set" + /> + <span className="text-body text-ink"> + An incomplete Task's Time block also closes a slot + </span> + </label> + + <div> + <p className="mb-1.5 text-label-sm font-medium text-ink">Conflict set</p> + {isEditing && bookingLink ? ( + <ul className="flex flex-col gap-1.5"> + {calendars.map((c) => { + const isBookInto = c.id === bookingLink.bookIntoCalendarId; + const isMember = isBookInto || bookingLink.conflictCalendarIds.includes(c.id); + return ( + <li key={c.id} className="flex items-center gap-2"> + <Checkbox + checked={isMember} + disabled={isBookInto} + onCheckedChange={() => handleToggleConflictCalendar(c.id, isMember)} + aria-label={ + isMember ? `Remove ${c.name} from the conflict set` : `Add ${c.name} to the conflict set` + } + /> + <span className="text-body text-ink"> + {c.name} + {isBookInto && <span className="text-ink-muted"> (book-into, always included)</span>} + </span> + </li> + ); + })} + </ul> + ) : ( + <p className="text-label-sm text-ink-muted"> + Defaults to every calendar you own in this workspace, plus whichever one you book into. You + can customize it once the link is created. + </p> + )} + </div> + </div> + )} + + {error && ( + <p className="text-label-sm text-danger" role="alert"> + {error} + </p> + )} + + <div className="mt-1 flex justify-end gap-2"> + <Dialog.Close + className={buttonClasses({ variant: "outline", color: "secondary", size: "small" })} + disabled={isSubmitting} + > + Cancel + </Dialog.Close> + <Button type="submit" size="small" loading={isSubmitting} disabled={!canSubmit}> + Save + </Button> + </div> + </form> + </Dialog.Popup> + </Dialog.Portal> + </Dialog.Root> + ); +} diff --git a/src/components/layout/BookingLinksSection.test.tsx b/src/components/layout/BookingLinksSection.test.tsx new file mode 100644 index 0000000..1548753 --- /dev/null +++ b/src/components/layout/BookingLinksSection.test.tsx @@ -0,0 +1,236 @@ +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 AvailabilitySchedulesSection.test.tsx: the *Api module +// is mocked, the stores are real (#322, ADR-0084, ADR-0087) — this covers +// the sidebar section's wiring, not the request/response shape itself. +vi.mock("../../lib/bookingLinksApi", async () => { + const actual = await vi.importActual<typeof import("../../lib/bookingLinksApi")>("../../lib/bookingLinksApi"); + return { + ...actual, + bookingLinksApi: { + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + duplicate: vi.fn(), + addConflictCalendar: vi.fn(), + removeConflictCalendar: vi.fn(), + }, + }; +}); +vi.mock("../../lib/authApi", async () => { + const actual = await vi.importActual<typeof import("../../lib/authApi")>("../../lib/authApi"); + return { ...actual, authApi: { ...actual.authApi, getHandleSuggestion: vi.fn(), me: vi.fn() } }; +}); +vi.mock("../../lib/availabilitySchedulesApi", async () => { + const actual = await vi.importActual<typeof import("../../lib/availabilitySchedulesApi")>( + "../../lib/availabilitySchedulesApi", + ); + return { ...actual, availabilitySchedulesApi: { ...actual.availabilitySchedulesApi, list: vi.fn() } }; +}); + +const { bookingLinksApi } = await import("../../lib/bookingLinksApi"); +const { authApi } = await import("../../lib/authApi"); +const { availabilitySchedulesApi } = await import("../../lib/availabilitySchedulesApi"); +const { useAuthStore } = await import("../../lib/authStore"); +const { useWorkspacesStore } = await import("../../lib/workspacesStore"); +const { useCalendarsStore } = await import("../../lib/calendarsStore"); +const { useAvailabilitySchedulesStore } = await import("../../lib/availabilitySchedulesStore"); +const { useBookingLinksStore } = await import("../../lib/bookingLinksStore"); +const { BookingLinksSection } = await import("./BookingLinksSection"); + +const user = { + id: 1, + name: "Ada", + mustChangePassword: false, + email: "ada@example.com", + emailReminderChannelAvailable: false, + googleProviderAvailable: false, + invitationRepliesConfigured: false, + syncedDeviceRemindersEnabled: false, + weekStart: 1, + defaultView: "week" as const, + timeFormat: "24h" as const, + workingHoursStart: null, + workingHoursEnd: null, + handle: "ada", +}; + +const workCalendar = { id: "cal-1", name: "Work", color: "#12809CFF", access: "owner" as const }; + +const introCall = { + id: 1, + title: "Intro call", + slug: "intro-call", + durationMinutes: 30, + visibility: "public" as const, + availabilityScheduleId: 1, + bookIntoCalendarId: "cal-1", + location: "", + description: "", + minimumNoticeMinutes: 240, + bookingHorizonDays: 60, + tasksInConflictSet: true, + conflictCalendarIds: ["cal-1"], +}; + +beforeEach(() => { + vi.clearAllMocks(); + useAuthStore.setState({ status: "authenticated", user, accessToken: "token-123" }); + useWorkspacesStore.setState({ activeWorkspaceId: 7 }); + useCalendarsStore.setState({ calendars: [workCalendar] }); + useAvailabilitySchedulesStore.setState({ schedules: [{ id: 1, name: "Default", tzid: "Etc/UTC", ranges: [] }] }); + useBookingLinksStore.setState({ bookingLinks: [] }); + vi.mocked(bookingLinksApi.list).mockResolvedValue([]); + vi.mocked(authApi.getHandleSuggestion).mockResolvedValue("ada-2"); + vi.mocked(availabilitySchedulesApi.list).mockResolvedValue([]); + Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } }); +}); + +describe("BookingLinksSection — listing", () => { + it("shows a placeholder when there are no links", async () => { + render(<BookingLinksSection />); + + expect(await screen.findByText("No booking links yet.")).toBeInTheDocument(); + }); + + it("lists a link with its duration and visibility", async () => { + vi.mocked(bookingLinksApi.list).mockResolvedValue([introCall]); + render(<BookingLinksSection />); + + expect(await screen.findByText("Intro call")).toBeInTheDocument(); + expect(screen.getByText("30m · Public")).toBeInTheDocument(); + }); +}); + +describe("BookingLinksSection — creating", () => { + it("creates a new link from the modal", async () => { + render(<BookingLinksSection />); + await waitFor(() => expect(bookingLinksApi.list).toHaveBeenCalled()); + + vi.mocked(bookingLinksApi.create).mockResolvedValue({ ...introCall, id: 2, title: "Deep dive" }); + + await userEvent.click(await screen.findByRole("button", { name: "New booking link" })); + await userEvent.type(await screen.findByLabelText("Title"), "Deep dive"); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(bookingLinksApi.create).toHaveBeenCalled()); + const call = vi.mocked(bookingLinksApi.create).mock.calls[0]; + expect(call[1]).toMatchObject({ + title: "Deep dive", + slug: "deep-dive", + durationMinutes: 30, + visibility: "public", + minimumNoticeMinutes: 240, + bookingHorizonDays: 60, + tasksInConflictSet: true, + availabilityScheduleId: 1, + bookIntoCalendarId: "cal-1", + }); + expect(await screen.findByText("Deep dive")).toBeInTheDocument(); + }); +}); + +describe("BookingLinksSection — copying", () => { + it("copies the link's URL to the clipboard", async () => { + vi.mocked(bookingLinksApi.list).mockResolvedValue([introCall]); + render(<BookingLinksSection />); + + await userEvent.click(await screen.findByRole("button", { name: "Copy Intro call's link" })); + + await waitFor(() => + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(`${window.location.origin}/ada/intro-call`), + ); + }); +}); + +describe("BookingLinksSection — duplicating", () => { + it("duplicates a link from its overflow menu", async () => { + vi.mocked(bookingLinksApi.list).mockResolvedValue([introCall]); + vi.mocked(bookingLinksApi.duplicate).mockResolvedValue({ ...introCall, id: 3, slug: "intro-call-copy", title: "Intro call (copy)" }); + render(<BookingLinksSection />); + + await userEvent.click(await screen.findByRole("button", { name: "Intro call actions" })); + await userEvent.click(await screen.findByRole("menuitem", { name: "Duplicate" })); + + await waitFor(() => expect(bookingLinksApi.duplicate).toHaveBeenCalledWith("token-123", 1)); + expect(await screen.findByText("Intro call (copy)")).toBeInTheDocument(); + }); +}); + +describe("BookingLinksSection — deleting", () => { + it("deletes a link after confirming", async () => { + vi.mocked(bookingLinksApi.list).mockResolvedValue([introCall]); + vi.mocked(bookingLinksApi.remove).mockResolvedValue(undefined); + render(<BookingLinksSection />); + + await userEvent.click(await screen.findByRole("button", { name: "Intro call actions" })); + await userEvent.click(await screen.findByRole("menuitem", { name: "Delete" })); + expect(await screen.findByText("Delete Intro call?")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Delete link" })); + + await waitFor(() => expect(bookingLinksApi.remove).toHaveBeenCalledWith("token-123", 1)); + await waitFor(() => expect(screen.getByText("No booking links yet.")).toBeInTheDocument()); + }); +}); + +// #322, ADR-0084: "Changing a Slug after publishing warns that the old URL +// will stop working" — exercised through the sidebar's own Edit action +// rather than by rendering BookingLinkModal directly, so this also proves +// the overflow menu actually wires Edit to it. +describe("BookingLinksSection — editing warns before a Slug rename", () => { + it("warns, and proceeds when confirmed", async () => { + vi.mocked(bookingLinksApi.list).mockResolvedValue([introCall]); + vi.spyOn(window, "confirm").mockReturnValue(true); + vi.mocked(bookingLinksApi.update).mockResolvedValue({ ...introCall, slug: "renamed" }); + render(<BookingLinksSection />); + + await userEvent.click(await screen.findByRole("button", { name: "Intro call actions" })); + await userEvent.click(await screen.findByRole("menuitem", { name: "Edit" })); + + const slugInput = await screen.findByLabelText("Slug"); + await userEvent.clear(slugInput); + await userEvent.type(slugInput, "renamed"); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(window.confirm).toHaveBeenCalledWith( + expect.stringContaining('breaks the URL published under "intro-call"'), + ); + await waitFor(() => + expect(bookingLinksApi.update).toHaveBeenCalledWith("token-123", 1, expect.objectContaining({ slug: "renamed" })), + ); + }); + + it("does not submit when the rename warning is declined", async () => { + vi.mocked(bookingLinksApi.list).mockResolvedValue([introCall]); + vi.spyOn(window, "confirm").mockReturnValue(false); + render(<BookingLinksSection />); + + await userEvent.click(await screen.findByRole("button", { name: "Intro call actions" })); + await userEvent.click(await screen.findByRole("menuitem", { name: "Edit" })); + + const slugInput = await screen.findByLabelText("Slug"); + await userEvent.clear(slugInput); + await userEvent.type(slugInput, "renamed"); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(bookingLinksApi.update).not.toHaveBeenCalled(); + }); + + it("does not warn when saving without changing the Slug", async () => { + vi.mocked(bookingLinksApi.list).mockResolvedValue([introCall]); + const confirmSpy = vi.spyOn(window, "confirm"); + vi.mocked(bookingLinksApi.update).mockResolvedValue(introCall); + render(<BookingLinksSection />); + + await userEvent.click(await screen.findByRole("button", { name: "Intro call actions" })); + await userEvent.click(await screen.findByRole("menuitem", { name: "Edit" })); + await userEvent.click(await screen.findByRole("button", { name: "Save" })); + + expect(confirmSpy).not.toHaveBeenCalled(); + await waitFor(() => expect(bookingLinksApi.update).toHaveBeenCalled()); + }); +}); diff --git a/src/components/layout/BookingLinksSection.tsx b/src/components/layout/BookingLinksSection.tsx new file mode 100644 index 0000000..866f5dd --- /dev/null +++ b/src/components/layout/BookingLinksSection.tsx @@ -0,0 +1,159 @@ +import { useEffect, useState } from "react"; +import { Menu } from "@base-ui/react/menu"; +import { Copy, MoreVertical, Plus } from "lucide-react"; +import { IconButton } from "../ui/IconButton"; +import { iconButtonClasses } from "../ui/iconButtonClasses"; +import { useAuthStore } from "../../lib/authStore"; +import { useBookingLinksStore } from "../../lib/bookingLinksStore"; +import { useWorkspacesStore } from "../../lib/workspacesStore"; +import { errorMessage } from "../../lib/errorMessage"; +import { toast } from "../../lib/toast"; +import type { BookingLink } from "../../lib/bookingLinksApi"; +import { BookingLinkModal } from "./BookingLinkModal"; +import { DeleteBookingLinkConfirmation } from "./DeleteBookingLinkConfirmation"; + +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 = + "flex cursor-default items-center px-3 py-1.5 text-body text-danger data-[highlighted]:bg-danger-50"; + +function formatDuration(minutes: number): string { + if (minutes % 60 === 0) return `${minutes / 60}h`; + if (minutes < 60) return `${minutes}m`; + return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; +} + +const VISIBILITY_LABELS: Record<BookingLink["visibility"], string> = { + public: "Public", + private: "Private", + paused: "Paused", +}; + +// The Booking links sidebar section (#322, ADR-0084, ADR-0087), below the +// Calendar list. Lists the Active Workspace's own links only — a Booking +// Link is scoped (User, Workspace) on the same terms as a Task List, and +// dies with the membership that gave it a Calendar to write into. Nothing +// here is public yet (#324): the copy button copies a URL that doesn't +// resolve, which is deliberate — see BookingLinkModal's own preview. +export function BookingLinksSection() { + const user = useAuthStore((state) => state.user); + const activeWorkspaceId = useWorkspacesStore((state) => state.activeWorkspaceId); + const bookingLinks = useBookingLinksStore((state) => state.bookingLinks); + const fetchBookingLinks = useBookingLinksStore((state) => state.fetchBookingLinks); + const duplicateBookingLink = useBookingLinksStore((state) => state.duplicateBookingLink); + + const [loadError, setLoadError] = useState<string | null>(null); + const [isCreating, setIsCreating] = useState(false); + const [editingTarget, setEditingTarget] = useState<BookingLink | null>(null); + const [deleteTarget, setDeleteTarget] = useState<BookingLink | null>(null); + + useEffect(() => { + if (activeWorkspaceId === null) return; + fetchBookingLinks() + .then(() => setLoadError(null)) + .catch((err) => setLoadError(errorMessage(err))); + }, [activeWorkspaceId, fetchBookingLinks]); + + function urlFor(link: BookingLink): string { + const handle = user?.handle ?? ""; + return `${window.location.origin}/${handle}/${link.slug}`; + } + + async function handleCopy(link: BookingLink) { + try { + await navigator.clipboard.writeText(urlFor(link)); + toast.success("Link copied."); + } catch { + toast.error("Failed to copy link."); + } + } + + async function handleDuplicate(link: BookingLink) { + try { + await duplicateBookingLink(link.id); + } catch (err) { + toast.error(errorMessage(err)); + } + } + + return ( + <div> + <div className="flex items-center justify-between py-2 ps-5 pe-2"> + <p className="text-label-sm font-medium text-ink-muted">Booking links</p> + <IconButton size="tiny" onClick={() => setIsCreating(true)} aria-label="New booking link"> + <Plus className="size-4" /> + </IconButton> + </div> + + {loadError && <p className="px-5 text-label-sm text-danger">{loadError}</p>} + + <ul> + {bookingLinks.map((link) => ( + <li + key={link.id} + className="group flex items-center gap-2 rounded-e-full py-2 ps-5 pe-2 transition-colors hover:bg-surface-hover" + > + <span className="flex min-w-0 flex-1 flex-col"> + <span className="min-w-0 truncate text-body text-ink">{link.title}</span> + <span className="truncate text-label-sm text-ink-muted"> + {formatDuration(link.durationMinutes)} · {VISIBILITY_LABELS[link.visibility]} + </span> + </span> + + <IconButton + size="tiny" + onClick={() => handleCopy(link)} + aria-label={`Copy ${link.title}'s link`} + title={`Copy ${link.title}'s link`} + className="opacity-0 focus-visible:opacity-100 group-hover:opacity-100" + > + <Copy className="size-3.5" /> + </IconButton> + + <Menu.Root> + <Menu.Trigger + aria-label={`${link.title} actions`} + title={`${link.title} actions`} + className={iconButtonClasses({ + size: "tiny", + className: + "opacity-0 focus-visible:opacity-100 group-hover:opacity-100 data-[popup-open]:opacity-100", + })} + > + <MoreVertical className="size-3.5" /> + </Menu.Trigger> + <Menu.Portal> + <Menu.Positioner sideOffset={4} align="end" className="z-[60]"> + <Menu.Popup className="rounded-shell-md border border-border bg-surface py-1 shadow-elevation-2"> + <Menu.Item onClick={() => setEditingTarget(link)} className={menuItemClasses}> + Edit + </Menu.Item> + <Menu.Item onClick={() => handleDuplicate(link)} className={menuItemClasses}> + Duplicate + </Menu.Item> + <div role="separator" className="my-1 border-t border-border" /> + <Menu.Item onClick={() => setDeleteTarget(link)} className={destructiveMenuItemClasses}> + Delete + </Menu.Item> + </Menu.Popup> + </Menu.Positioner> + </Menu.Portal> + </Menu.Root> + </li> + ))} + + {bookingLinks.length === 0 && !loadError && ( + <p className="px-5 text-label-sm text-ink-muted">No booking links yet.</p> + )} + </ul> + + {isCreating && <BookingLinkModal mode="create" onClose={() => setIsCreating(false)} />} + {editingTarget && ( + <BookingLinkModal mode="edit" bookingLink={editingTarget} onClose={() => setEditingTarget(null)} /> + )} + {deleteTarget && ( + <DeleteBookingLinkConfirmation bookingLink={deleteTarget} onClose={() => setDeleteTarget(null)} /> + )} + </div> + ); +} diff --git a/src/components/layout/CalendarList.test.tsx b/src/components/layout/CalendarList.test.tsx index eadcdb8..de07b8c 100644 --- a/src/components/layout/CalendarList.test.tsx +++ b/src/components/layout/CalendarList.test.tsx @@ -1,29 +1,43 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Route, Routes } from "react-router"; import type { Calendar } from "../../lib/calendar"; // Same convention as the other component tests: the *Api modules are mocked, // the stores are real. -vi.mock("../../lib/calendarsApi", () => ({ 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<typeof import("../../lib/connectionsApi")>("../../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", () => ({ 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<typeof import("../../lib/calendarSetsApi")>("../../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"); 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 { calendarSetsApi } = await import("../../lib/calendarSetsApi"); const { CalendarList } = await import("./CalendarList"); const owned: Calendar = { @@ -49,9 +63,26 @@ beforeEach(() => { vi.clearAllMocks(); useAuthStore.setState({ accessToken: "token-123" }); useEventsStore.setState({ events: [] }); - useShellStore.setState({ checkedCalendarIds: new Set<string>() }); + useShellStore.setState({ checkedCalendarIds: new Set<string>(), activeCalendarSetId: null }); + useConnectionsStore.setState({ connections: [] }); + useCalendarSetsStore.setState({ calendarSets: [] }); }); +// CalendarList now navigates to Settings' Calendar sets Section from the +// empty-Set state (#306), so every render needs a Router context for +// useNavigate to work. The destination route is a stand-in, not the real +// Section — CalendarList only needs to prove it asked to go there. +function renderCalendarList() { + return render( + <MemoryRouter> + <Routes> + <Route path="/" element={<CalendarList />} /> + <Route path="/settings/calendar-sets" element={<div>Calendar sets destination</div>} /> + </Routes> + </MemoryRouter>, + ); +} + async function openMenu(calendarName: string) { await userEvent.click(screen.getByRole("button", { name: `${calendarName} actions` })); } @@ -59,7 +90,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(<CalendarList />); + renderCalendarList(); await openMenu("Personal"); @@ -68,7 +99,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(<CalendarList />); + renderCalendarList(); await openMenu("Team"); @@ -89,6 +120,7 @@ describe("Linked Calendar grouping", () => { access: "viewer", isOwner: true, sourceKind: "connection", + connectionId: 1, connectionAccountEmail: "work@gmail.com", }; const personalLinked: Calendar = { @@ -98,12 +130,13 @@ describe("Linked Calendar grouping", () => { access: "viewer", isOwner: true, sourceKind: "connection", + connectionId: 2, connectionAccountEmail: "personal@gmail.com", }; it("shows one heading per Connection's account email", () => { useCalendarsStore.setState({ calendars: [owned, workLinked, personalLinked] }); - render(<CalendarList />); + renderCalendarList(); expect(screen.getByText("work@gmail.com")).toBeInTheDocument(); expect(screen.getByText("personal@gmail.com")).toBeInTheDocument(); @@ -113,7 +146,7 @@ describe("Linked Calendar grouping", () => { it("keeps a Linked Calendar out of My calendars", () => { useCalendarsStore.setState({ calendars: [owned, workLinked] }); - render(<CalendarList />); + renderCalendarList(); expect(screen.getByText("My calendars")).toBeInTheDocument(); // "Work" only ever renders under its Connection's own heading. @@ -126,7 +159,7 @@ describe("Linked Calendar grouping", () => { useWorkspacesStore.setState({ activeWorkspaceId: 7 }); vi.mocked(connectionsApi.listPickerCalendars).mockResolvedValue([]); useCalendarsStore.setState({ calendars: [{ ...workLinked, connectionId: 42 }] }); - render(<CalendarList />); + renderCalendarList(); await userEvent.click( screen.getByRole("button", { name: "Choose calendars from work@gmail.com" }), @@ -148,10 +181,11 @@ describe("Linked Calendar grouping", () => { isOwner: false, ownerName: "Bob", sourceKind: "connection", + connectionId: 3, connectionAccountEmail: "bob@gmail.com", }; useCalendarsStore.setState({ calendars: [owned, sharedLinked] }); - render(<CalendarList />); + renderCalendarList(); expect(screen.getByText("Shared with me")).toBeInTheDocument(); // No Connection heading is rendered — that grouping is the Owner's, and @@ -161,11 +195,53 @@ 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] }); + renderCalendarList(); + + 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] }); + renderCalendarList(); + + 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 () => { useCalendarsStore.setState({ calendars: [workLinked] }); - render(<CalendarList />); + renderCalendarList(); await openMenu("Work"); @@ -173,3 +249,343 @@ 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] }); + renderCalendarList(); + + 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] }); + renderCalendarList(); + + 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] }); + renderCalendarList(); + + 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] }); + renderCalendarList(); + + 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); + }); +}); + +// #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, + }); + renderCalendarList(); + + 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, + }); + renderCalendarList(); + + 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, + }); + renderCalendarList(); + + 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, + }); + renderCalendarList(); + + 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, + }); + renderCalendarList(); + + 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"); + }); +}); + +// #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(); + }); +}); + +// #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 eba441e..f3b273d 100644 --- a/src/components/layout/CalendarList.tsx +++ b/src/components/layout/CalendarList.tsx @@ -1,14 +1,21 @@ -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"; +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"; 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 { inScopeCalendars } from "../../lib/calendarSetScope"; +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"; import { useEventsStore } from "../../lib/eventsStore"; import { useShellStore } from "../../lib/shellStore"; import { deleteCalendarCascade } from "../../lib/deleteCalendarCascade"; @@ -27,6 +34,10 @@ 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'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"; const destructiveMenuItemClasses = @@ -45,6 +56,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); @@ -52,7 +64,31 @@ export function CalendarList() { const toggleCalendarChecked = useShellStore( (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 + // 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 @@ -80,36 +116,36 @@ 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<Set<string>>(new Set()); - // 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". - const linkedCalendarsByConnection = new Map< - string, - { connectionId?: number; calendars: Calendar[] } - >(); - 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 sharedCalendars = calendars.filter( - (calendar) => !canManageCalendar(calendar), - ); + // 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); + // 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 + // lives. + const { myCalendars, subscribedCalendars, linkedByConnection: linkedCalendarsByConnection, sharedCalendars } = + groupCalendarsForSidebar(visibleCalendars); const deletingCalendar = calendars.find( (calendar) => calendar.id === deletingCalendarId, @@ -189,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); @@ -328,6 +388,77 @@ export function CalendarList() { Export </Menu.Item> )} + {/* 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 && ( + <Menu.CheckboxItem + checked={Boolean(calendar.exposed)} + onCheckedChange={(checked) => setCalendarExposure(calendar.id, checked)} + closeOnClick={false} + className={menuItemClasses} + > + <Menu.CheckboxItemIndicator + keepMounted + className="mr-2 flex size-3.5 shrink-0 items-center justify-center data-[unchecked]:opacity-0" + > + <Check className="size-3.5" /> + </Menu.CheckboxItemIndicator> + Show on my devices + </Menu.CheckboxItem> + )} + {/* 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 && ( + <Menu.SubmenuRoot> + <Menu.SubmenuTrigger className={`${menuItemClasses} justify-between`}> + Add to set + <ChevronRight className="ml-2 size-3.5 shrink-0 text-ink-muted" /> + </Menu.SubmenuTrigger> + <Menu.Portal> + <Menu.Positioner sideOffset={4} className="z-[60]"> + <Menu.Popup className="rounded-shell-md border border-border bg-surface py-1 shadow-elevation-2"> + {calendarSets.map((calendarSet) => { + const isMember = calendarSet.calendarIds.includes(calendar.id); + const isPending = pendingSetToggles.has(`${calendar.id}:${calendarSet.id}`); + return ( + <Menu.CheckboxItem + key={calendarSet.id} + checked={isMember} + disabled={isPending} + closeOnClick={false} + onCheckedChange={() => + handleToggleSetMembership(calendar, calendarSet, isMember) + } + aria-label={ + isMember + ? `Remove ${calendar.name} from ${calendarSet.name}` + : `Add ${calendar.name} to ${calendarSet.name}` + } + className={menuItemClasses} + > + <Menu.CheckboxItemIndicator + keepMounted + className="mr-2 flex size-3.5 shrink-0 items-center justify-center data-[unchecked]:opacity-0" + > + <Check className="size-3.5" /> + </Menu.CheckboxItemIndicator> + <span className="min-w-0 truncate">{calendarSet.name}</span> + </Menu.CheckboxItem> + ); + })} + </Menu.Popup> + </Menu.Positioner> + </Menu.Portal> + </Menu.SubmenuRoot> + )} <div role="separator" className="my-1 border-t border-border" /> {canManage ? ( <Menu.Item @@ -357,55 +488,95 @@ export function CalendarList() { return ( <div> - <div className="flex items-center justify-between py-2 ps-5 pe-2"> - <p className="text-label-sm font-medium text-ink-muted"> - My calendars - </p> - <IconButton - size="tiny" - onClick={() => setIsCreateOpen(true)} - aria-label="Add calendar" - > - <Plus className="size-4" /> - </IconButton> - </div> - <ul>{myCalendars.map(renderCalendarItem)}</ul> + {isEmptySet && activeCalendarSet && ( + <div className="flex flex-col items-center gap-3 px-5 py-10 text-center"> + <Layers className="size-8 text-ink-muted" aria-hidden="true" /> + <p className="text-body text-ink"> + "{activeCalendarSet.name}" has no calendars in it. + </p> + <div className="flex flex-col items-stretch gap-2"> + <Button + size="small" + onClick={() => navigate("/settings/calendar-sets")} + > + Manage sets + </Button> + <Button + variant="ghost" + size="small" + onClick={() => setActiveCalendarSetId(null)} + > + Back to All calendars + </Button> + </div> + </div> + )} - <div className="flex items-center justify-between py-2 ps-5 pe-2"> - <p className="text-label-sm font-medium text-ink-muted"> - Subscribed calendars - </p> - <IconButton - size="tiny" - onClick={() => setIsSubscribeOpen(true)} - aria-label="Subscribe to a calendar" - > - <Plus className="size-4" /> - </IconButton> - </div> - <ul>{subscribedCalendars.map(renderCalendarItem)}</ul> + {/* 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. + isEmptySet already implies myCalendars is empty here, so no + separate guard is needed for it. */} + {(!isSetActive || myCalendars.length > 0) && ( + <> + <div className="flex items-center justify-between py-2 ps-5 pe-2"> + <p className="text-label-sm font-medium text-ink-muted"> + My calendars + </p> + <IconButton + size="tiny" + onClick={() => setIsCreateOpen(true)} + aria-label="Add calendar" + > + <Plus className="size-4" /> + </IconButton> + </div> + <ul>{myCalendars.map(renderCalendarItem)}</ul> + </> + )} + + {(!isSetActive || subscribedCalendars.length > 0) && ( + <> + <div className="flex items-center justify-between py-2 ps-5 pe-2"> + <p className="text-label-sm font-medium text-ink-muted"> + Subscribed calendars + </p> + <IconButton + size="tiny" + onClick={() => setIsSubscribeOpen(true)} + aria-label="Subscribe to a calendar" + > + <Plus className="size-4" /> + </IconButton> + </div> + <ul>{subscribedCalendars.map(renderCalendarItem)}</ul> + </> + )} {/* 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]) => ( - <div key={accountEmail}> - <div className="flex items-center justify-between py-2 ps-5 pe-2"> - <p className="text-label-sm font-medium text-ink-muted">{accountEmail}</p> - {group.connectionId !== undefined && ( - <IconButton - size="tiny" - onClick={() => setPickerConnectionId(group.connectionId ?? null)} - aria-label={`Choose calendars from ${accountEmail}`} - title={`Choose calendars from ${accountEmail}`} - > - <Plus className="size-4" /> - </IconButton> - )} + {Array.from(linkedCalendarsByConnection.entries()).map(([connectionId, group]) => { + const accountEmail = connectionGroupLabel(connectionId, group, connections); + return ( + <div key={connectionId === UNRESOLVED_CONNECTION ? "unresolved-connection" : connectionId}> + <div className="flex items-center justify-between py-2 ps-5 pe-2"> + <p className="text-label-sm font-medium text-ink-muted">{accountEmail}</p> + {connectionId !== UNRESOLVED_CONNECTION && ( + <IconButton + size="tiny" + onClick={() => setPickerConnectionId(connectionId)} + aria-label={`Choose calendars from ${accountEmail}`} + title={`Choose calendars from ${accountEmail}`} + > + <Plus className="size-4" /> + </IconButton> + )} + </div> + <ul>{group.map(renderCalendarItem)}</ul> </div> - <ul>{group.calendars.map(renderCalendarItem)}</ul> - </div> - ))} + ); + })} {sharedCalendars.length > 0 && ( <> 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<typeof import("../../lib/calendarSetsApi")>( + "../../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<string>(), activeCalendarSetId: null }); + useCalendarSetsStore.setState({ calendarSets: [] }); +}); + +describe("CalendarModal add-to-set checkbox (#308)", () => { + it("is absent when on All calendars", () => { + render(<CalendarModal mode="create" onClose={vi.fn()} />); + 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( + <CalendarModal + mode="edit" + calendar={{ id: "cal-1", name: "Personal", color: "#8E44ADFF", access: "owner", isOwner: true }} + onClose={vi.fn()} + />, + ); + 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(<CalendarModal mode="create" onClose={vi.fn()} />); + + 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(<CalendarModal mode="create" onClose={vi.fn()} />); + + 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(<CalendarModal mode="create" onClose={vi.fn()} />); + + 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..5b94d57 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"; @@ -15,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"; @@ -44,6 +46,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 +162,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 +243,14 @@ export function CalendarModal(props: CalendarModalProps) { </div> </div> + {mode === "create" && ( + <AddToActiveSetCheckbox + activeCalendarSet={activeCalendarSet} + checked={addToActiveSet} + onCheckedChange={setAddToActiveSet} + /> + )} + {calendarId && ( <> {/* Default reminders (ADR-0064): fire on every Event on this diff --git a/src/components/layout/CalendarSetSwitcher.tsx b/src/components/layout/CalendarSetSwitcher.tsx new file mode 100644 index 0000000..9189158 --- /dev/null +++ b/src/components/layout/CalendarSetSwitcher.tsx @@ -0,0 +1,93 @@ +import { useEffect } from "react"; +import { Menu } from "@base-ui/react/menu"; +import { Check, ChevronDown } from "lucide-react"; +import { useNavigate } from "react-router"; +import { useShellStore } from "../../lib/shellStore"; +import { useCalendarSetsStore } from "../../lib/calendarSetsStore"; +import { useWorkspacesStore } from "../../lib/workspacesStore"; + +// The Menu's radio 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"; + +// Matches CalendarList.tsx's and UserMenu.tsx's own Menu.Item classes +// (cursor-default, edge-to-edge highlight), not Select.tsx's — this menu is +// built on the Menu primitive, not Select, and its items should look like +// this codebase's other menus rather than a dropdown's. +const itemClasses = + "flex cursor-default items-center gap-3 px-3 py-1.5 text-body text-ink data-[highlighted]:bg-surface-hover"; + +// The top-bar Calendar Set switcher (#303, #304, ADR-0082): lists "All +// calendars" plus the caller's own Sets in the active Workspace, and selects +// between them. Built on the Menu primitive rather than WorkspaceSwitcher's +// Select (ADR-0082) so it can carry a divider and a "Manage sets" entry +// alongside the Sets themselves. Always rendered once a Workspace is active, +// even at zero Sets, so the feature is discoverable before the caller has +// made one, and creating the first Set never changes the trigger's own +// label — only selecting it does — so the top bar never reflows from that +// alone. +export function CalendarSetSwitcher() { + const navigate = useNavigate(); + 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; + + const activeCalendarSet = calendarSets.find((calendarSet) => calendarSet.id === activeCalendarSetId); + const radioValue = activeCalendarSet ? String(activeCalendarSet.id) : ALL_CALENDARS; + + return ( + <Menu.Root> + <Menu.Trigger + aria-label="Select calendar set" + className="flex cursor-pointer items-center gap-1.5 rounded-shell-pill bg-surface-sunken px-4 py-1.5 text-body text-ink ring-1 ring-border transition-colors outline-none hover:bg-surface-hover data-[popup-open]:ring-2 data-[popup-open]:ring-accent-ink" + > + <span className="min-w-0 truncate">{activeCalendarSet?.name ?? "All calendars"}</span> + <ChevronDown className="size-4 shrink-0 text-ink-muted" /> + </Menu.Trigger> + <Menu.Portal> + <Menu.Positioner sideOffset={4} className="z-[60]"> + <Menu.Popup className="min-w-[--anchor-width] rounded-shell-md border border-border bg-surface py-1.5 shadow-elevation-2"> + <Menu.RadioGroup + value={radioValue} + onValueChange={(value: string) => + setActiveCalendarSetId(value === ALL_CALENDARS ? null : Number(value)) + } + > + <Menu.RadioItem value={ALL_CALENDARS} closeOnClick className={itemClasses}> + <Menu.RadioItemIndicator keepMounted className="flex size-4 shrink-0 items-center justify-center data-[unchecked]:opacity-0"> + <Check className="size-4 text-accent-ink" /> + </Menu.RadioItemIndicator> + All calendars + </Menu.RadioItem> + {calendarSets.map((calendarSet) => ( + <Menu.RadioItem key={calendarSet.id} value={String(calendarSet.id)} closeOnClick className={itemClasses}> + <Menu.RadioItemIndicator keepMounted className="flex size-4 shrink-0 items-center justify-center data-[unchecked]:opacity-0"> + <Check className="size-4 text-accent-ink" /> + </Menu.RadioItemIndicator> + <span className="min-w-0 truncate">{calendarSet.name}</span> + </Menu.RadioItem> + ))} + </Menu.RadioGroup> + <div role="separator" className="my-1 border-t border-border" /> + <Menu.Item onClick={() => navigate("/settings/calendar-sets")} className={itemClasses}> + Manage sets + </Menu.Item> + </Menu.Popup> + </Menu.Positioner> + </Menu.Portal> + </Menu.Root> + ); +} diff --git a/src/components/layout/DeleteBookingLinkConfirmation.tsx b/src/components/layout/DeleteBookingLinkConfirmation.tsx new file mode 100644 index 0000000..42e54fc --- /dev/null +++ b/src/components/layout/DeleteBookingLinkConfirmation.tsx @@ -0,0 +1,63 @@ +import { Dialog } from "@base-ui/react/dialog"; +import { Button } from "../ui/Button"; +import { buttonClasses } from "../ui/buttonClasses"; +import { useAsyncAction } from "../../hooks/useAsyncAction"; +import { useBookingLinksStore } from "../../lib/bookingLinksStore"; +import type { BookingLink } from "../../lib/bookingLinksApi"; + +interface DeleteBookingLinkConfirmationProps { + bookingLink: BookingLink; + onClose: () => void; +} + +// Deletes a Booking Link outright (#322). Nothing is public yet (that +// arrives with #324), so there is no visitor-facing consequence to warn +// about — just the ordinary "this cannot be undone". +export function DeleteBookingLinkConfirmation({ bookingLink, onClose }: DeleteBookingLinkConfirmationProps) { + const deleteBookingLink = useBookingLinksStore((state) => state.deleteBookingLink); + const { isSubmitting, error, run } = useAsyncAction(); + + async function handleConfirm() { + await run(async () => { + await deleteBookingLink(bookingLink.id); + onClose(); + }); + } + + return ( + <Dialog.Root + open + onOpenChange={(open) => { + if (!open && !isSubmitting) onClose(); + }} + > + <Dialog.Portal> + <Dialog.Backdrop className="fixed inset-0 z-40 bg-ink/20" /> + <Dialog.Popup className="fixed top-1/2 left-1/2 z-50 w-80 -translate-x-1/2 -translate-y-1/2 rounded-shell-lg bg-surface p-5 shadow-elevation-3"> + <Dialog.Title className="text-heading font-medium text-ink">Delete {bookingLink.title}?</Dialog.Title> + <Dialog.Description className="mt-2 text-body text-ink-muted"> + This removes the booking link. This cannot be undone. + </Dialog.Description> + + {error && ( + <p className="mt-2 text-label-sm text-danger" role="alert"> + {error} + </p> + )} + + <div className="mt-5 flex justify-end gap-2"> + <Dialog.Close + className={buttonClasses({ variant: "outline", color: "secondary", size: "small" })} + disabled={isSubmitting} + > + Cancel + </Dialog.Close> + <Button color="danger" size="small" loading={isSubmitting} onClick={handleConfirm}> + Delete link + </Button> + </div> + </Dialog.Popup> + </Dialog.Portal> + </Dialog.Root> + ); +} 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 ( + <AlertDialog.Root + open + onOpenChange={(open) => { + if (!open) onClose(); + }} + > + <AlertDialog.Portal> + <AlertDialog.Backdrop className="fixed inset-0 z-40 bg-ink/20" /> + <AlertDialog.Popup className="fixed top-1/2 left-1/2 z-50 w-80 -translate-x-1/2 -translate-y-1/2 rounded-shell-lg bg-surface p-5 shadow-elevation-3"> + <AlertDialog.Title className="text-heading font-medium text-ink"> + Delete "{task.title}"? + </AlertDialog.Title> + <AlertDialog.Description className="mt-2 text-body text-ink-muted"> + This can't be undone. + </AlertDialog.Description> + + <div className="mt-5 flex justify-end gap-2"> + <AlertDialog.Close + className={buttonClasses({ + variant: "outline", + color: "secondary", + size: "small", + })} + > + Cancel + </AlertDialog.Close> + <Button color="danger" size="small" onClick={onConfirm}> + Delete + </Button> + </div> + </AlertDialog.Popup> + </AlertDialog.Portal> + </AlertDialog.Root> + ); +} 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" && ( <UserPlus className="size-3.5 shrink-0 text-ink-muted" /> )} + {notification.kind === "writeback_failed" && ( + <AlertTriangle className="size-3.5 shrink-0 text-danger" /> + )} <span className="text-body text-ink"> {notification.title} </span> @@ -80,7 +87,9 @@ export function NotificationBell() { <p className="text-label-sm text-ink-muted"> {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/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 3adeaa7..976f503 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,6 +1,7 @@ import { Plus } from "lucide-react"; import { MiniCalendar } from "./MiniCalendar"; import { CalendarList } from "./CalendarList"; +import { BookingLinksSection } from "./BookingLinksSection"; import { Button } from "../ui/Button"; interface SidebarProps { @@ -19,6 +20,7 @@ export function Sidebar({ onCreateClick }: SidebarProps) { </Button> <MiniCalendar /> <CalendarList /> + <BookingLinksSection /> </div> ); } 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<typeof import("../../lib/calendarSetsApi")>( + "../../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<string>(), 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(<SubscribeCalendarModal onClose={vi.fn()} />); + 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(<SubscribeCalendarModal onClose={vi.fn()} />); + 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(<SubscribeCalendarModal onClose={vi.fn()} />); + 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(<SubscribeCalendarModal onClose={vi.fn()} />); + 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..79c5e97 100644 --- a/src/components/layout/SubscribeCalendarModal.tsx +++ b/src/components/layout/SubscribeCalendarModal.tsx @@ -2,11 +2,14 @@ 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 { AddToActiveSetCheckbox } from "./AddToActiveSetCheckbox"; import { ColorSwatchPicker } from "./ColorSwatchPicker"; import { Button } from "../ui/Button"; import { buttonClasses } from "../ui/buttonClasses"; @@ -40,6 +43,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<SubscriptionPreview | null>(null); @@ -88,6 +96,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 +206,12 @@ export function SubscribeCalendarModal({ onClose }: SubscribeCalendarModalProps) this instance will send </span> </label> + + <AddToActiveSetCheckbox + activeCalendarSet={activeCalendarSet} + checked={addToActiveSet} + onCheckedChange={setAddToActiveSet} + /> </> )} 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 ( + <BaseCheckbox.Root + checked={checked} + onCheckedChange={onCheckedChange} + aria-label={ariaLabel} + className="flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-surface data-[checked]:border-accent-ink data-[checked]:bg-accent" + > + <BaseCheckbox.Indicator className="flex items-center justify-center text-on-accent"> + <Check className="size-3" /> + </BaseCheckbox.Indicator> + </BaseCheckbox.Root> + ); +} 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 ( + <> + <Dialog.Root + open + onOpenChange={(open) => { + if (!open) onClose(); + }} + > + <Dialog.Portal> + <Dialog.Backdrop className="fixed inset-0 z-40 bg-ink/20" /> + <Dialog.Popup className="fixed top-1/2 left-1/2 z-50 w-96 -translate-x-1/2 -translate-y-1/2 rounded-shell-lg bg-surface p-5 shadow-elevation-3"> + <div className="flex items-center justify-between gap-2"> + <Dialog.Title className="min-w-0 flex-1 truncate text-heading font-medium text-ink"> + {task.title} + </Dialog.Title> + <IconButton + size="small" + color="danger" + aria-label="Delete task" + onClick={() => setIsDeleteConfirmOpen(true)} + > + <Trash2 className="size-4" /> + </IconButton> + </div> + + <Textarea + label="Notes" + value={notes} + onChange={(event) => setNotes(event.target.value)} + onBlur={commitNotes} + placeholder="Add notes" + className="mt-4" + /> + + <div className="mt-4 flex items-end gap-2"> + <Input + label="Deadline" + type="date" + value={task.due ? dateToDateInputValue(task.due) : ""} + onChange={(event) => handleDeadlineChange(event.target.value)} + className="flex-1" + /> + {task.due && ( + <IconButton + size="small" + aria-label="Clear deadline" + onClick={handleClearDeadline} + > + <X className="size-4" /> + </IconButton> + )} + </div> + + <Select + label="Priority" + value={priorityLevelFromValue(task.priority)} + onValueChange={handlePriorityChange} + options={PRIORITY_LEVELS.map((level) => ({ + value: level, + label: PRIORITY_LEVEL_LABELS[level], + }))} + className="mt-4" + /> + + <Select + label="Task list" + value={String(task.taskListId)} + onValueChange={handleTaskListChange} + options={taskLists.map((taskList) => ({ + value: String(taskList.id), + label: taskList.name, + }))} + className="mt-4" + /> + + <div className="mt-5 flex justify-end"> + <Dialog.Close + className={buttonClasses({ + variant: "outline", + color: "secondary", + size: "small", + })} + > + Done + </Dialog.Close> + </div> + </Dialog.Popup> + </Dialog.Portal> + </Dialog.Root> + {isDeleteConfirmOpen && ( + <DeleteTaskConfirmation + task={task} + onConfirm={handleConfirmDelete} + onClose={() => setIsDeleteConfirmOpen(false)} + /> + )} + </> + ); +} diff --git a/src/components/layout/TaskListToggle.tsx b/src/components/layout/TaskListToggle.tsx new file mode 100644 index 0000000..742579c --- /dev/null +++ b/src/components/layout/TaskListToggle.tsx @@ -0,0 +1,45 @@ +import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox"; +import { Check } from "lucide-react"; +import { getContrastTextColor, toOpaqueHex } from "../../lib/calendarColors"; + +interface TaskListToggleProps { + checked: boolean; + onCheckedChange: (checked: boolean) => 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 ( + <BaseCheckbox.Root + checked={checked} + onCheckedChange={onCheckedChange} + aria-label={ariaLabel} + className="flex size-4 shrink-0 items-center justify-center rounded-[4px] border" + style={{ + borderColor: opaqueColor, + backgroundColor: checked ? opaqueColor : "transparent", + }} + > + <BaseCheckbox.Indicator + className="flex items-center justify-center" + style={{ color: getContrastTextColor(opaqueColor) }} + > + <Check className="size-3" /> + </BaseCheckbox.Indicator> + </BaseCheckbox.Root> + ); +} 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(<TaskListsFilter />); + + 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(<TaskListsFilter />); + 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(<TaskListsFilter />); + + 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(<TaskListsFilter />); + 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 ( + <div className="flex flex-col gap-2"> + <Menu.Root> + <Menu.Trigger + aria-label="Lists filter" + className="flex w-full cursor-pointer items-center justify-between gap-2 rounded-shell-md bg-surface-sunken px-3 py-2 text-body text-ink ring-1 ring-border transition-colors outline-none hover:bg-surface-hover data-[popup-open]:ring-2 data-[popup-open]:ring-accent-ink" + > + <span>Lists</span> + <span className="flex items-center gap-1 text-label-sm text-ink-muted"> + {checkedCount} of {taskLists.length} + <ChevronDown className="size-4" /> + </span> + </Menu.Trigger> + <Menu.Portal> + <Menu.Positioner sideOffset={4} className="z-[60]"> + <Menu.Popup className="min-w-[--anchor-width] rounded-shell-md border border-border bg-surface py-1.5 shadow-elevation-2"> + {taskLists.map((taskList) => ( + <div key={taskList.id} className={menuItemClasses}> + <TaskListToggle + checked={checkedTaskListIds.has(taskList.id)} + onCheckedChange={() => toggleTaskListChecked(taskList.id)} + color={taskList.color} + aria-label={taskList.name} + /> + <span className="min-w-0 flex-1 truncate">{taskList.name}</span> + </div> + ))} + <div role="separator" className="my-1 border-t border-border" /> + <Menu.Item onClick={() => setIsCreating(true)} className={menuItemClasses}> + <Plus className="size-4" /> + New list + </Menu.Item> + </Menu.Popup> + </Menu.Positioner> + </Menu.Portal> + </Menu.Root> + {isCreating && ( + <input + autoFocus + value={draftName} + onChange={(event) => 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" + /> + )} + </div> + ); +} diff --git a/src/components/layout/TaskQuickAdd.tsx b/src/components/layout/TaskQuickAdd.tsx new file mode 100644 index 0000000..6058114 --- /dev/null +++ b/src/components/layout/TaskQuickAdd.tsx @@ -0,0 +1,47 @@ +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; + + 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."); + } + } + + return ( + <input + value={title} + onChange={(event) => 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..2253af7 --- /dev/null +++ b/src/components/layout/TaskRow.tsx @@ -0,0 +1,175 @@ +import { format } from "date-fns"; +import { Menu } from "@base-ui/react/menu"; +import { MoreVertical } from "lucide-react"; +import { getCalendarBlockStyle } from "../../lib/calendarColors"; +import { computeMoveToDate } from "../../lib/gridTime"; +import { usePointerDrag } from "../../hooks/usePointerDrag"; +import { useTaskListsStore } from "../../lib/taskListsStore"; +import { getTaskListById } from "../../lib/taskListsApi"; +import { taskDropIntent } from "../../lib/taskDropIntent"; +import { taskTimeBlockEnd } from "../../lib/taskTimeBlockSegments"; +import type { Task } from "../../lib/tasksApi"; +import { useTasksStore } from "../../lib/tasksStore"; +import { TaskCompletionControl } from "./TaskCompletionControl"; +import { TaskDragPreview } from "../../calendar-grid/TaskDragPreview"; +import { resolveGridDropTime, resolveMonthDropDate } from "../../calendar-grid/gridDropTargets"; +import { iconButtonClasses } from "../ui/iconButtonClasses"; + +interface TaskRowProps { + task: Task; + onOpenDetail: (task: Task) => void; +} + +const menuItemClasses = + "flex cursor-default items-center px-3 py-1.5 text-body text-ink data-[highlighted]:bg-surface-hover"; + +// TaskRow is one row inside a Task bucket (#310, #311, #313, #315, +// ADR-0083): its own Deadline beside the title, so Thursday's work reads +// differently from Friday's inside the same bucket. A plain click opens the +// detail surface; a drag onto the hourly grid gives it a Time block instead +// (issue #309 story 47). A drag onto a Month Day cell moves that Time block, +// preserving its time-of-day, if the Task already has one — the row still +// shows for an already-scheduled Task, bucketed by its own start when it +// carries no Deadline — and otherwise gives it a Deadline instead: Month +// never invents an hour from a gesture that named none (#315, ADR-0083). +// Reuses `usePointerDrag` — the same gesture-pure mousedown-origin / +// window-mousemove-mouseup hook every draggable Occurrence uses. It works +// unmodified here even though the Task drag starts in a different DOM +// subtree than the grid it can land on: `usePointerDrag` never touches the +// DOM itself, and `TimeGrid`'s own all-day drag already resolves its drop +// target the same elementFromPoint way, from inside its `onDrag`. +export function TaskRow({ task, onOpenDetail }: TaskRowProps) { + const setTaskCompleted = useTasksStore((state) => state.setTaskCompleted); + const setTaskTimeBlock = useTasksStore((state) => state.setTaskTimeBlock); + const clearTaskTimeBlock = useTasksStore((state) => state.clearTaskTimeBlock); + const setTaskDeadlineAndClearTimeBlock = useTasksStore( + (state) => state.setTaskDeadlineAndClearTimeBlock, + ); + const taskList = useTaskListsStore((state) => getTaskListById(state.taskLists, task.taskListId)); + + const drag = usePointerDrag<Task>({ + onClick: onOpenDetail, + onDrag: (draggedTask, state) => { + const monthDate = resolveMonthDropDate(state.position.x, state.position.y); + if (monthDate) { + // A row in the panel isn't always a fresh, unscheduled Task — one + // already carrying a Time block still shows here (bucketed by its + // own start when it has no Deadline), so this drag has to read as + // "timeBlock", not a blanket "panelRow", or dropping it on a Month + // Day cell would wrongly wipe that block into a Deadline instead of + // preserving its time-of-day (#315, ADR-0083). + if (draggedTask.start) { + const { start } = computeMoveToDate(draggedTask.start, taskTimeBlockEnd(draggedTask), monthDate); + const write = taskDropIntent( + { kind: "timeBlock", durationMinutes: draggedTask.durationMinutes as number }, + { surface: "monthDay", date: start }, + ); + if (write.action !== "setTimeBlock") return; + void setTaskTimeBlock(draggedTask.id, write.start, write.durationMinutes); + return; + } + + const write = taskDropIntent({ kind: "panelRow" }, { surface: "monthDay", date: monthDate }); + if (write.action !== "setDeadlineAndClearTimeBlock") return; + void setTaskDeadlineAndClearTimeBlock(draggedTask.id, write.due); + return; + } + + const dropTime = resolveGridDropTime(state.position.x, state.position.y); + if (!dropTime) return; + const write = taskDropIntent({ kind: "panelRow" }, { surface: "hourlyGrid", time: dropTime }); + if (write.action !== "setTimeBlock") return; + void setTaskTimeBlock(draggedTask.id, write.start, write.durationMinutes); + }, + }); + + return ( + <> + <div + onMouseDown={(domEvent) => drag.start(task, domEvent.clientX, domEvent.clientY)} + className="group flex cursor-pointer items-center gap-2 rounded-shell-sm py-1.5 hover:bg-surface-hover" + > + <span + // Stops the completion click (and the mousedown that precedes it) + // from also starting a drag or opening the detail surface — the + // two are one row's two separate gestures. + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > + <TaskCompletionControl + checked={task.completed} + onCheckedChange={(checked) => { + void setTaskCompleted(task.id, checked); + }} + aria-label={task.completed ? `Mark "${task.title}" incomplete` : `Mark "${task.title}" complete`} + /> + </span> + <span + className={`min-w-0 flex-1 truncate text-body ${ + task.completed ? "text-ink-muted line-through" : "text-ink" + }`} + > + {task.title} + </span> + {task.due && ( + <span className="shrink-0 text-label-sm text-ink-muted">{format(task.due, "MMM d")}</span> + )} + {/* Absent rather than disabled when there's no Time block to clear + (mirrors CalendarList's own row-menu philosophy) — there being + nothing else on this menu yet, that's the same as showing no + trigger at all (#314). Placed here, not the detail modal, so + removing a block never requires opening it first — the same + "no precise drag required" reasoning the menu item itself answers + to (issue #309 story 58). */} + {task.start && ( + <Menu.Root> + <Menu.Trigger + aria-label={`"${task.title}" actions`} + title={`"${task.title}" actions`} + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + className={iconButtonClasses({ + size: "tiny", + className: + "shrink-0 opacity-0 focus-visible:opacity-100 group-hover:opacity-100 data-[popup-open]:opacity-100", + })} + > + <MoreVertical className="size-3.5" /> + </Menu.Trigger> + <Menu.Portal> + <Menu.Positioner sideOffset={4} align="end" className="z-[60]"> + {/* Rendered through a portal, so it sits outside the row's + own DOM subtree — but React still bubbles its synthetic + events up through the *component* tree the JSX describes, + all the way to the row div's own onMouseDown. Without + this guard, mousing down on an item both starts the row's + drag gesture and (since the mouseup that follows the + click never moves) fires its onClick, reopening the + detail surface right after "Unschedule" runs. */} + <Menu.Popup + onMouseDown={(event) => event.stopPropagation()} + className="rounded-shell-md border border-border bg-surface py-1 shadow-elevation-2" + > + <Menu.Item + onClick={() => void clearTaskTimeBlock(task.id)} + className={menuItemClasses} + > + Unschedule + </Menu.Item> + </Menu.Popup> + </Menu.Positioner> + </Menu.Portal> + </Menu.Root> + )} + </div> + {drag.active && ( + <TaskDragPreview + x={drag.position.x} + y={drag.position.y} + title={task.title} + blockStyle={getCalendarBlockStyle(taskList)} + /> + )} + </> + ); +} diff --git a/src/components/layout/TasksPanel.tsx b/src/components/layout/TasksPanel.tsx new file mode 100644 index 0000000..bd66c42 --- /dev/null +++ b/src/components/layout/TasksPanel.tsx @@ -0,0 +1,281 @@ +import { useEffect, useState } from "react"; +import { Menu } from "@base-ui/react/menu"; +import { Check, ChevronDown, ChevronRight, MoreVertical, X } from "lucide-react"; +import { toOpaqueHex } from "../../lib/calendarColors"; +import { viewerZone } from "../../lib/floatingTime"; +import { type TasksPanelAxis, useShellStore } from "../../lib/shellStore"; +import { + PRIORITY_HEADING_ORDER, + TASK_BUCKET_LABELS, + TASK_BUCKET_ORDER, + bucketTasks, + tasksByPriorityLevel, + tasksByTaskList, +} from "../../lib/taskScheduling"; +import { PRIORITY_LEVEL_LABELS } from "../../lib/taskPriority"; +import { useTaskListsStore } from "../../lib/taskListsStore"; +import type { TaskList } from "../../lib/taskListsApi"; +import type { Task } from "../../lib/tasksApi"; +import { useTasksStore } from "../../lib/tasksStore"; +import { useWorkspacesStore } from "../../lib/workspacesStore"; +import { IconButton } from "../ui/IconButton"; +import { iconButtonClasses } from "../ui/iconButtonClasses"; +import { TaskDetailModal } from "./TaskDetailModal"; +import { TaskListsFilter } from "./TaskListsFilter"; +import { TaskQuickAdd } from "./TaskQuickAdd"; +import { TaskRow } from "./TaskRow"; + +const menuItemClasses = + "flex cursor-default items-center gap-2 px-3 py-1.5 text-body text-ink data-[highlighted]:bg-surface-hover"; + +// A heading the panel currently renders — one of the three axes' own +// vocabulary (Task bucket / Task List / Priority label, never "group"; see +// TasksPanelAxis) collapsed to what JSX actually needs to draw one: a key, +// a label, an optional colour, and its own pre-sorted Tasks. +interface PanelHeading { + key: string; + label: string; + color: string | null; + tasks: Task[]; +} + +// computePanelHeadings resolves tasksPanelAxis into what JSX renders, +// keeping each axis's own rule in one place (#316, ADR-0083): Task bucket +// always renders its four headings via bucketTasks; Priority always renders +// its four via tasksByPriorityLevel/PRIORITY_HEADING_ORDER; Task List +// renders one heading per *checked* Task List, in list order, so an +// unchecked list's Tasks fall out the same way the Lists filter already +// implies elsewhere. Ordering within a heading is unchanged across axes — +// every branch's Tasks arrive pre-sorted by compareTasksWithinHeading. +function computePanelHeadings( + axis: TasksPanelAxis, + tasks: Task[], + taskLists: TaskList[], + checkedTaskListIds: Set<number>, +): PanelHeading[] { + if (axis === "taskList") { + const byTaskList = tasksByTaskList(tasks); + return taskLists + .filter((taskList) => checkedTaskListIds.has(taskList.id)) + .map((taskList) => ({ + key: String(taskList.id), + label: taskList.name, + color: taskList.color, + tasks: byTaskList.get(taskList.id) ?? [], + })); + } + + if (axis === "priority") { + const byPriority = tasksByPriorityLevel(tasks); + return PRIORITY_HEADING_ORDER.map((level) => ({ + key: level, + label: PRIORITY_LEVEL_LABELS[level], + color: null, + tasks: byPriority[level], + })); + } + + const buckets = bucketTasks(tasks, new Date(), viewerZone()); + return TASK_BUCKET_ORDER.map((bucket) => ({ + key: bucket, + label: TASK_BUCKET_LABELS[bucket], + color: null, + tasks: buckets[bucket], + })); +} + +// TasksPanel is the right-hand panel holding a User's Tasks (#310, #311, +// #316, #317, ADR-0083): toggled from the top bar's Tasks button, closed by +// default, squeezing the grid rather than overlaying it. Arranges its Tasks +// under one of three headings at a time — Task bucket (the default), Task +// List or Priority — chosen from the "…" menu's Group by submenu and held +// as tasksPanelAxis, derived at render either way (ADR-0083). Each heading +// carries its own count and each row shows its own Deadline. A completed +// Task, once "Show completed" is on, rejoins its own heading rather than +// sitting in a separate pile: one finished late still reads as Overdue, +// struck through, because it was (CONTEXT.md's Completed entry). +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 showTasksOnCalendar = useShellStore((state) => state.showTasksOnCalendar); + const setShowTasksOnCalendar = useShellStore((state) => state.setShowTasksOnCalendar); + const tasksPanelAxis = useShellStore((state) => state.tasksPanelAxis); + const setTasksPanelAxis = useShellStore((state) => state.setTasksPanelAxis); + const checkedTaskListIds = useShellStore((state) => state.checkedTaskListIds); + const taskLists = useTaskListsStore((state) => state.taskLists); + const activeWorkspaceId = useWorkspacesStore((state) => state.activeWorkspaceId); + const [selectedTask, setSelectedTask] = useState<Task | null>(null); + + 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]); + + // selectedTask is looked up by id off the store on every render rather + // than held as the object itself, so an edit made inside TaskDetailModal + // (which replaces the Task in the store) is reflected back into the + // still-open modal instead of showing a stale snapshot. + const openTask = selectedTask + ? (tasks.find((t) => t.id === selectedTask.id) ?? + completedTasks.find((t) => t.id === selectedTask.id) ?? + null) + : null; + + const visibleTasks = showCompletedTasks ? [...tasks, ...completedTasks] : tasks; + const headings = computePanelHeadings(tasksPanelAxis, visibleTasks, taskLists, checkedTaskListIds); + // Reads off headings rather than visibleTasks.length: the Task List axis + // can hold Tasks with no checked heading to render them under (every Task + // List unchecked), in which case there's nothing to show even though + // visibleTasks itself isn't empty. + const hasVisibleTasks = headings.some((heading) => heading.tasks.length > 0); + + return ( + <div + // Read by TimeGrid's grid-block drag (#314) via `isPointOverTasksPanel`, + // the panel's own drop-target counterpart to DayColumn's + // `data-grid-day-ms` and AllDayLane's `data-allday-date` — the same + // elementFromPoint technique, this time asking only "is the pointer + // over the panel at all" rather than resolving a day or a time. + data-tasks-panel + className="flex h-full flex-col gap-3 overflow-y-auto p-4" + > + <div className="flex items-center justify-between"> + <h2 className="text-heading text-ink">Tasks</h2> + <div className="flex items-center gap-1"> + <Menu.Root> + <Menu.Trigger + aria-label="Tasks panel options" + className={iconButtonClasses({ size: "small" })} + > + <MoreVertical className="size-4" /> + </Menu.Trigger> + <Menu.Portal> + <Menu.Positioner sideOffset={4} align="end" className="z-[60]"> + <Menu.Popup className="min-w-56 rounded-shell-md border border-border bg-surface py-1 shadow-elevation-2"> + <Menu.CheckboxItem + checked={showTasksOnCalendar} + onCheckedChange={setShowTasksOnCalendar} + className={menuItemClasses} + > + <Menu.CheckboxItemIndicator + keepMounted + className="flex size-4 shrink-0 items-center justify-center data-[unchecked]:opacity-0" + > + <Check className="size-4 text-accent-ink" /> + </Menu.CheckboxItemIndicator> + Show tasks on calendar + </Menu.CheckboxItem> + <div role="separator" className="my-1 border-t border-border" /> + <Menu.SubmenuRoot> + <Menu.SubmenuTrigger className={`${menuItemClasses} justify-between`}> + Group by + <ChevronRight className="ml-2 size-3.5 shrink-0 text-ink-muted" /> + </Menu.SubmenuTrigger> + <Menu.Portal> + <Menu.Positioner sideOffset={4} className="z-[60]"> + <Menu.Popup className="min-w-44 rounded-shell-md border border-border bg-surface py-1 shadow-elevation-2"> + <Menu.RadioGroup + value={tasksPanelAxis} + onValueChange={(value: string) => + setTasksPanelAxis(value as TasksPanelAxis) + } + > + <Menu.RadioItem value="taskBucket" closeOnClick className={menuItemClasses}> + <Menu.RadioItemIndicator + keepMounted + className="flex size-4 shrink-0 items-center justify-center data-[unchecked]:opacity-0" + > + <Check className="size-4 text-accent-ink" /> + </Menu.RadioItemIndicator> + Task bucket + </Menu.RadioItem> + <Menu.RadioItem value="taskList" closeOnClick className={menuItemClasses}> + <Menu.RadioItemIndicator + keepMounted + className="flex size-4 shrink-0 items-center justify-center data-[unchecked]:opacity-0" + > + <Check className="size-4 text-accent-ink" /> + </Menu.RadioItemIndicator> + Task List + </Menu.RadioItem> + <Menu.RadioItem value="priority" closeOnClick className={menuItemClasses}> + <Menu.RadioItemIndicator + keepMounted + className="flex size-4 shrink-0 items-center justify-center data-[unchecked]:opacity-0" + > + <Check className="size-4 text-accent-ink" /> + </Menu.RadioItemIndicator> + Priority + </Menu.RadioItem> + </Menu.RadioGroup> + </Menu.Popup> + </Menu.Positioner> + </Menu.Portal> + </Menu.SubmenuRoot> + </Menu.Popup> + </Menu.Positioner> + </Menu.Portal> + </Menu.Root> + <IconButton + size="small" + onClick={() => setTasksPanelOpen(false)} + aria-label="Close Tasks panel" + > + <X className="size-4" /> + </IconButton> + </div> + </div> + <TaskListsFilter /> + <TaskQuickAdd /> + {!hasVisibleTasks ? ( + <p className="text-body text-ink-muted">No tasks yet.</p> + ) : ( + <div className="flex flex-col gap-4"> + {headings.map((heading) => ( + <div key={heading.key} className="flex flex-col"> + <h3 + className="text-label-sm font-medium text-ink-muted" + style={heading.color ? { color: toOpaqueHex(heading.color) } : undefined} + > + {heading.label} ({heading.tasks.length}) + </h3> + {heading.tasks.map((task) => ( + <TaskRow key={task.id} task={task} onOpenDetail={setSelectedTask} /> + ))} + </div> + ))} + </div> + )} + <button + type="button" + onClick={() => setShowCompletedTasks(!showCompletedTasks)} + className="flex cursor-pointer items-center gap-1 self-start text-label-sm text-ink-muted hover:text-ink" + > + {showCompletedTasks ? ( + <ChevronDown className="size-4" /> + ) : ( + <ChevronRight className="size-4" /> + )} + Show completed + </button> + {openTask && ( + <TaskDetailModal key={openTask.id} task={openTask} onClose={() => setSelectedTask(null)} /> + )} + </div> + ); +} diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index f1fe93c..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"; @@ -6,6 +6,7 @@ import { useVersion } from "../../hooks/useVersion"; import { navigateDate } from "../../lib/navigateDate"; import { formatDateLabel } from "../../lib/formatDateLabel"; import { UserMenu } from "../../auth/UserMenu"; +import { CalendarSetSwitcher } from "./CalendarSetSwitcher"; import { ViewSwitcher } from "./ViewSwitcher"; import { WorkspaceSwitcher } from "./WorkspaceSwitcher"; import { ThemeToggle } from "./ThemeToggle"; @@ -18,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(); @@ -63,9 +66,18 @@ export function TopBar() { <div className="ml-auto flex items-center gap-2"> <WorkspaceSwitcher /> + <CalendarSetSwitcher /> <ViewSwitcher /> <ThemeToggle /> <NotificationBell /> + <IconButton + onClick={() => setTasksPanelOpen(!tasksPanelOpen)} + aria-label="Tasks" + aria-pressed={tasksPanelOpen} + className={tasksPanelOpen ? "bg-surface-hover text-ink" : undefined} + > + <ListTodo className="size-5" /> + </IconButton> <IconButton onClick={() => navigate("/settings")} aria-label="Settings" diff --git a/src/hooks/useVisibleCalendarIds.ts b/src/hooks/useVisibleCalendarIds.ts new file mode 100644 index 0000000..7212250 --- /dev/null +++ b/src/hooks/useVisibleCalendarIds.ts @@ -0,0 +1,25 @@ +import { useMemo } from "react"; +import { getCheckedCalendars } from "../lib/calendar"; +import { inScopeCalendars } from "../lib/calendarSetScope"; +import { useActiveCalendarSet } from "../lib/calendarSetsStore"; +import { useCalendarsStore } from "../lib/calendarsStore"; +import { useShellStore } from "../lib/shellStore"; + +// useVisibleCalendarIds answers which Calendar ids the grid renders Events +// for right now (#303, ADR-0082): in the Active Calendar Set *and* toggled +// on. The grid's two consumers — useVisibleOccurrences (Day/Week/Month) and +// YearGrid — compose over this one answer instead of each re-deriving it, +// mirroring the sidebar's own use of inScopeCalendars. +export function useVisibleCalendarIds(): Set<string> { + const calendars = useCalendarsStore((state) => state.calendars); + const checkedCalendarIds = useShellStore((state) => state.checkedCalendarIds); + const activeCalendarSet = useActiveCalendarSet(); + + return useMemo(() => { + const visible = getCheckedCalendars( + inScopeCalendars(calendars, activeCalendarSet), + checkedCalendarIds, + ); + return new Set(visible.map((calendar) => calendar.id)); + }, [calendars, activeCalendarSet, checkedCalendarIds]); +} diff --git a/src/hooks/useVisibleOccurrences.ts b/src/hooks/useVisibleOccurrences.ts index 9264d6e..7579560 100644 --- a/src/hooks/useVisibleOccurrences.ts +++ b/src/hooks/useVisibleOccurrences.ts @@ -1,31 +1,32 @@ import { useMemo } from "react"; import { useEventsStore } from "../lib/eventsStore"; -import { useShellStore } from "../lib/shellStore"; import { expandOccurrences } from "../lib/expandOccurrences"; import type { Occurrence } from "../lib/occurrence"; +import { useVisibleCalendarIds } from "./useVisibleCalendarIds"; /** * The Occurrences visible in the half-open window `[windowStartMs, windowEndMs)`: - * every Event on a checked Calendar, expanded over that window (ADR-0016). The - * window is passed as epoch milliseconds so the memo is keyed on primitives — - * navigating within the same window re-uses the result. Shared by the Day/Week - * (TimeGrid) and Month grids, which differ only in how they derive the window. + * every Event on a Calendar that is both in the Active Calendar Set and checked + * (#303, ADR-0082), expanded over that window (ADR-0016). The window is passed + * as epoch milliseconds so the memo is keyed on primitives — navigating within + * the same window re-uses the result. Shared by the Day/Week (TimeGrid) and + * Month grids, which differ only in how they derive the window. */ export function useVisibleOccurrences( windowStartMs: number, windowEndMs: number, ): Occurrence[] { const events = useEventsStore((state) => state.events); - const checkedCalendarIds = useShellStore((state) => state.checkedCalendarIds); + const visibleCalendarIds = useVisibleCalendarIds(); return useMemo(() => { const visibleEvents = events.filter((event) => - checkedCalendarIds.has(event.calendarId), + visibleCalendarIds.has(event.calendarId), ); return expandOccurrences( visibleEvents, new Date(windowStartMs), new Date(windowEndMs), ); - }, [events, checkedCalendarIds, windowStartMs, windowEndMs]); + }, [events, visibleCalendarIds, windowStartMs, windowEndMs]); } diff --git a/src/index.css b/src/index.css index 3ab45e0..1351ef9 100644 --- a/src/index.css +++ b/src/index.css @@ -375,3 +375,23 @@ body { border-radius: var(--radius-shell-pill); background-color: var(--color-ink-muted); } + +/* The public Booking Link page's month grid (#324): a bookable day carries + an accent dot instead of the neutral Event-presence one above — a + different signal (availability, not density) on a page with no Events at + all. Every other day is left to react-day-picker's own `disabled` dimming. */ +.rdp-day-available { + position: relative; +} + +.rdp-day-available::after { + content: ""; + position: absolute; + bottom: 3px; + left: 50%; + width: 4px; + height: 4px; + transform: translateX(-50%); + border-radius: var(--radius-shell-pill); + background-color: var(--color-accent-ink); +} diff --git a/src/lib/authApi.ts b/src/lib/authApi.ts index 298fa30..b74c731 100644 --- a/src/lib/authApi.ts +++ b/src/lib/authApi.ts @@ -46,6 +46,9 @@ export interface User { // shading — the default. workingHoursStart: number | null; workingHoursEnd: number | null; + // Handle (#321, ADR-0084): a User's public name — unique instance-wide, + // compared case-insensitively. null until claimed in Settings → Account. + handle: string | null; } export interface LoginResult { @@ -72,6 +75,7 @@ interface MeWire { time_format: TimeFormat; working_hours_start: number | null; working_hours_end: number | null; + handle: string | null; } function fromMeWire(wire: MeWire): User { @@ -89,6 +93,7 @@ function fromMeWire(wire: MeWire): User { timeFormat: wire.time_format, workingHoursStart: wire.working_hours_start, workingHoursEnd: wire.working_hours_end, + handle: wire.handle, }; } @@ -285,6 +290,33 @@ export const authApi = { return fromMeWire(await response.json()); }, + // Claims or changes the caller's own Handle (#321, ADR-0084). + async updateHandle(accessToken: string, handle: string): Promise<User> { + const response = await authedFetch(accessToken, "/api/auth/handle", { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ handle }), + }); + if (!response.ok) throw await errorFromResponse(response); + + return fromMeWire(await response.json()); + }, + + // A Handle suggestion derived from the caller's own Email local part, + // already disambiguated against every claimed Handle and the reserved-word + // registry (#321, ADR-0084). Read-only — claiming it is a separate + // updateHandle call. + async getHandleSuggestion(accessToken: string): Promise<string> { + const response = await authedFetch(accessToken, "/api/auth/handle-suggestion", { + credentials: "include", + }); + if (!response.ok) throw await errorFromResponse(response); + + const body = (await response.json()) as { handle: string }; + return body.handle; + }, + async updateSyncedDeviceReminders(accessToken: string, enabled: boolean): Promise<User> { const response = await authedFetch(accessToken, "/api/auth/synced-device-reminders", { method: "PUT", diff --git a/src/lib/authStore.test.ts b/src/lib/authStore.test.ts index 5289fa5..9ae2f61 100644 --- a/src/lib/authStore.test.ts +++ b/src/lib/authStore.test.ts @@ -52,6 +52,7 @@ const adminUser = { timeFormat: "24h" as const, workingHoursStart: null, workingHoursEnd: null, + handle: null, }; function resetStore() { diff --git a/src/lib/authStore.ts b/src/lib/authStore.ts index a96d5d4..f85be61 100644 --- a/src/lib/authStore.ts +++ b/src/lib/authStore.ts @@ -39,6 +39,12 @@ interface AuthState { changePassword: (currentPassword: string, newPassword: string) => Promise<void>; updateEmail: (email: string) => Promise<User>; updateName: (name: string) => Promise<User>; + updateHandle: (handle: string) => Promise<User>; + // Re-fetches the caller's own User record without any field of its own + // changing here — for a side effect elsewhere on the server that this + // session wouldn't otherwise learn about, e.g. a Booking Link's first + // create auto-claiming a Handle (#322, ADR-0084). + refreshUser: () => Promise<User>; updateSyncedDeviceReminders: (enabled: boolean) => Promise<void>; updateWeekStart: (weekStart: number) => Promise<void>; updateDefaultView: (defaultView: ActiveView) => Promise<void>; @@ -243,6 +249,24 @@ export const useAuthStore = create<AuthState>((set, get) => { return user; }, + updateHandle: async (handle) => { + const { accessToken } = get(); + if (!accessToken) throw new Error("Not authenticated."); + + const user = await authApi.updateHandle(accessToken, handle); + set(authenticated(user, accessToken)); + return user; + }, + + refreshUser: async () => { + const { accessToken } = get(); + if (!accessToken) throw new Error("Not authenticated."); + + const user = await authApi.me(accessToken); + set(authenticated(user, accessToken)); + return user; + }, + updateSyncedDeviceReminders: async (enabled) => { const { accessToken } = get(); if (!accessToken) throw new Error("Not authenticated."); diff --git a/src/lib/availabilitySchedulesApi.ts b/src/lib/availabilitySchedulesApi.ts new file mode 100644 index 0000000..a0a3827 --- /dev/null +++ b/src/lib/availabilitySchedulesApi.ts @@ -0,0 +1,80 @@ +import { authedFetch, errorFromResponse } from "./apiClient"; + +// Availability Schedules (#320, ADR-0085): a named weekly pattern of time +// ranges belonging to one User, carrying its own IANA timezone. Unlike +// Calendar Sets and Task Lists, a Schedule carries no Workspace of its own — +// it is reusable across every Workspace a Booking Link might write into — +// so these calls carry no workspaceHeaders. + +export interface AvailabilityRange { + // 0-6, Sunday-Saturday, matching the Week start Preference's own + // convention. + weekday: number; + startMinute: number; + endMinute: number; +} + +export interface AvailabilitySchedule { + id: number; + name: string; + tzid: string; + ranges: AvailabilityRange[]; +} + +export const availabilitySchedulesApi = { + // Every Schedule the caller owns. The backend seeds a "Default" one the + // first time this returns none — tzHint, the caller's own + // browser-detected IANA zone, seeds that Default's timezone the one time + // it doesn't exist yet, and is ignored otherwise. + async list(accessToken: string, tzHint?: string): Promise<AvailabilitySchedule[]> { + const query = tzHint ? `?tz=${encodeURIComponent(tzHint)}` : ""; + const response = await authedFetch(accessToken, `/api/availability-schedules/${query}`, { + credentials: "include", + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as AvailabilitySchedule[]; + }, + + // Creates a new Schedule. ranges may be empty — an empty Schedule is + // legal (ADR-0085). + async create(accessToken: string, name: string, tzid: string, ranges: AvailabilityRange[]): Promise<AvailabilitySchedule> { + const response = await authedFetch(accessToken, "/api/availability-schedules/", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, tzid, ranges }), + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as AvailabilitySchedule; + }, + + // Replaces id's name, timezone and entire weekly range set at once. + async update( + accessToken: string, + id: number, + name: string, + tzid: string, + ranges: AvailabilityRange[], + ): Promise<AvailabilitySchedule> { + const response = await authedFetch(accessToken, `/api/availability-schedules/${id}`, { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, tzid, ranges }), + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as AvailabilitySchedule; + }, + + // Deletes id outright. + async remove(accessToken: string, id: number): Promise<void> { + const response = await authedFetch(accessToken, `/api/availability-schedules/${id}`, { + method: "DELETE", + credentials: "include", + }); + if (!response.ok) throw await errorFromResponse(response); + }, +}; diff --git a/src/lib/availabilitySchedulesStore.ts b/src/lib/availabilitySchedulesStore.ts new file mode 100644 index 0000000..8c77378 --- /dev/null +++ b/src/lib/availabilitySchedulesStore.ts @@ -0,0 +1,46 @@ +import { create } from "zustand"; +import { useAuthStore } from "./authStore"; +import { type AvailabilityRange, type AvailabilitySchedule, availabilitySchedulesApi } from "./availabilitySchedulesApi"; + +// The Availability Schedules Settings Section's data source (#320, +// ADR-0085). Mirrors calendarSetsStore's shape, minus any Workspace +// dependency — a Schedule belongs to the User alone, so there is nothing to +// re-fetch on Workspace switch. +interface AvailabilitySchedulesState { + schedules: AvailabilitySchedule[]; + fetchSchedules: (tzHint?: string) => Promise<void>; + createSchedule: (name: string, tzid: string, ranges: AvailabilityRange[]) => Promise<AvailabilitySchedule>; + updateSchedule: (id: number, name: string, tzid: string, ranges: AvailabilityRange[]) => Promise<void>; + deleteSchedule: (id: number) => Promise<void>; +} + +function requireAccessToken(): string { + const accessToken = useAuthStore.getState().accessToken; + if (!accessToken) throw new Error("Not authenticated."); + return accessToken; +} + +export const useAvailabilitySchedulesStore = create<AvailabilitySchedulesState>((set, get) => ({ + schedules: [], + + fetchSchedules: async (tzHint) => { + const schedules = await availabilitySchedulesApi.list(requireAccessToken(), tzHint); + set({ schedules }); + }, + + createSchedule: async (name, tzid, ranges) => { + const created = await availabilitySchedulesApi.create(requireAccessToken(), name, tzid, ranges); + set({ schedules: [...get().schedules, created] }); + return created; + }, + + updateSchedule: async (id, name, tzid, ranges) => { + const updated = await availabilitySchedulesApi.update(requireAccessToken(), id, name, tzid, ranges); + set({ schedules: get().schedules.map((s) => (s.id === id ? updated : s)) }); + }, + + deleteSchedule: async (id) => { + await availabilitySchedulesApi.remove(requireAccessToken(), id); + set({ schedules: get().schedules.filter((s) => s.id !== id) }); + }, +})); diff --git a/src/lib/bookingLinksApi.ts b/src/lib/bookingLinksApi.ts new file mode 100644 index 0000000..9d87702 --- /dev/null +++ b/src/lib/bookingLinksApi.ts @@ -0,0 +1,125 @@ +import { authedFetch, errorFromResponse } from "./apiClient"; +import { workspaceHeaders } from "./workspaceHeaders"; + +// Booking Links (#322, ADR-0084, ADR-0087): a named, slug-addressed offer to +// be booked, scoped to the active Workspace like a Task List. + +export type BookingLinkVisibility = "public" | "private" | "paused"; + +export interface BookingLink { + id: number; + title: string; + slug: string; + durationMinutes: number; + visibility: BookingLinkVisibility; + availabilityScheduleId: number; + bookIntoCalendarId: string; + location: string; + description: string; + minimumNoticeMinutes: number; + bookingHorizonDays: number; + tasksInConflictSet: boolean; + conflictCalendarIds: string[]; +} + +// BookingLinkWrite is Create and Update's shared body shape — Update, like +// Create, always takes every field together. +export interface BookingLinkWrite { + title: string; + slug: string; + durationMinutes: number; + visibility: BookingLinkVisibility; + availabilityScheduleId: number; + bookIntoCalendarId: string; + location: string; + description: string; + minimumNoticeMinutes: number; + bookingHorizonDays: number; + tasksInConflictSet: boolean; +} + +export const bookingLinksApi = { + // Every Booking Link the caller owns in the active Workspace. + async list(accessToken: string): Promise<BookingLink[]> { + const response = await authedFetch(accessToken, "/api/booking-links/", { + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as BookingLink[]; + }, + + // Creates a new Booking Link. Its Conflict set is seeded automatically — + // every Calendar the caller owns in the active Workspace, plus the + // Book-into Calendar itself, pinned. + async create(accessToken: string, write: BookingLinkWrite): Promise<BookingLink> { + const response = await authedFetch(accessToken, "/api/booking-links/", { + method: "POST", + credentials: "include", + headers: workspaceHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(write), + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as BookingLink; + }, + + // Replaces id's fields wholesale. + async update(accessToken: string, id: number, write: BookingLinkWrite): Promise<BookingLink> { + const response = await authedFetch(accessToken, `/api/booking-links/${id}`, { + method: "PATCH", + credentials: "include", + headers: workspaceHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(write), + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as BookingLink; + }, + + // Deletes id outright. + async remove(accessToken: string, id: number): Promise<void> { + const response = await authedFetch(accessToken, `/api/booking-links/${id}`, { + method: "DELETE", + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + }, + + // Clones id into a new Booking Link with a fresh Slug and Title, copying + // its Conflict set verbatim. + async duplicate(accessToken: string, id: number): Promise<BookingLink> { + const response = await authedFetch(accessToken, `/api/booking-links/${id}/duplicate`, { + method: "POST", + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + + return (await response.json()) as BookingLink; + }, + + // Puts calendarId into id's Conflict set (ADR-0087). PUT, not POST — + // re-adding a Calendar already in the set is a no-op, not a conflict. + async addConflictCalendar(accessToken: string, id: number, calendarId: string): Promise<void> { + const response = await authedFetch(accessToken, `/api/booking-links/${id}/conflict-set/calendars/${calendarId}`, { + method: "PUT", + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + }, + + // Takes calendarId out of id's Conflict set. Refused by the backend for + // the link's own Book-into Calendar — permanently in the set. + async removeConflictCalendar(accessToken: string, id: number, calendarId: string): Promise<void> { + const response = await authedFetch(accessToken, `/api/booking-links/${id}/conflict-set/calendars/${calendarId}`, { + method: "DELETE", + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + }, +}; diff --git a/src/lib/bookingLinksStore.ts b/src/lib/bookingLinksStore.ts new file mode 100644 index 0000000..3fb9303 --- /dev/null +++ b/src/lib/bookingLinksStore.ts @@ -0,0 +1,82 @@ +import { create } from "zustand"; +import { useAuthStore } from "./authStore"; +import { type BookingLink, type BookingLinkWrite, bookingLinksApi } from "./bookingLinksApi"; + +// The Booking links sidebar section's data source (#322, ADR-0084, +// ADR-0087). Mirrors taskListsStore's shape — a Booking Link is scoped +// (User, Workspace) on the same terms as a Task List. +interface BookingLinksState { + bookingLinks: BookingLink[]; + fetchBookingLinks: () => Promise<void>; + createBookingLink: (write: BookingLinkWrite) => Promise<BookingLink>; + updateBookingLink: (id: number, write: BookingLinkWrite) => Promise<BookingLink>; + deleteBookingLink: (id: number) => Promise<void>; + duplicateBookingLink: (id: number) => Promise<BookingLink>; + addConflictCalendar: (id: number, calendarId: string) => Promise<void>; + removeConflictCalendar: (id: number, calendarId: string) => Promise<void>; +} + +function requireAccessToken(): string { + const accessToken = useAuthStore.getState().accessToken; + if (!accessToken) throw new Error("Not authenticated."); + return accessToken; +} + +export const useBookingLinksStore = create<BookingLinksState>((set, get) => ({ + bookingLinks: [], + + fetchBookingLinks: async () => { + const bookingLinks = await bookingLinksApi.list(requireAccessToken()); + set({ bookingLinks }); + }, + + createBookingLink: async (write) => { + const created = await bookingLinksApi.create(requireAccessToken(), write); + set({ bookingLinks: [...get().bookingLinks, created] }); + // The first Booking Link a User creates claims their Handle for them + // (ADR-0084) — this session's own authStore.user wouldn't otherwise + // learn about it until its next unrelated refresh. + useAuthStore.getState().refreshUser().catch(() => {}); + return created; + }, + + updateBookingLink: async (id, write) => { + const updated = await bookingLinksApi.update(requireAccessToken(), id, write); + set({ bookingLinks: get().bookingLinks.map((l) => (l.id === id ? updated : l)) }); + return updated; + }, + + deleteBookingLink: async (id) => { + await bookingLinksApi.remove(requireAccessToken(), id); + set({ bookingLinks: get().bookingLinks.filter((l) => l.id !== id) }); + }, + + duplicateBookingLink: async (id) => { + const duplicated = await bookingLinksApi.duplicate(requireAccessToken(), id); + set({ bookingLinks: [...get().bookingLinks, duplicated] }); + useAuthStore.getState().refreshUser().catch(() => {}); + return duplicated; + }, + + // Both immediate-effect, no save step, mirroring calendarSetsStore's own + // addCalendarToSet/removeCalendarFromSet. + addConflictCalendar: async (id, calendarId) => { + await bookingLinksApi.addConflictCalendar(requireAccessToken(), id, calendarId); + set({ + bookingLinks: get().bookingLinks.map((l) => + l.id === id && !l.conflictCalendarIds.includes(calendarId) + ? { ...l, conflictCalendarIds: [...l.conflictCalendarIds, calendarId] } + : l, + ), + }); + }, + + removeConflictCalendar: async (id, calendarId) => { + await bookingLinksApi.removeConflictCalendar(requireAccessToken(), id, calendarId); + set({ + bookingLinks: get().bookingLinks.map((l) => + l.id === id ? { ...l, conflictCalendarIds: l.conflictCalendarIds.filter((c) => c !== calendarId) } : l, + ), + }); + }, +})); diff --git a/src/lib/calendar.test.ts b/src/lib/calendar.test.ts index 1e9f59f..f1fedb9 100644 --- a/src/lib/calendar.test.ts +++ b/src/lib/calendar.test.ts @@ -303,18 +303,18 @@ describe("defaultCalendarId", () => { // "everything's read-only" must stay distinguishable rather than collapsing // into one generic message. describe("calendarPickerEmptyReason", () => { - it("is 'none' when the caller has no calendars at all", () => { - expect(calendarPickerEmptyReason([], new Set())).toBe("none"); + it("is 'none' when the caller has no calendars at all and no Active Calendar Set", () => { + expect(calendarPickerEmptyReason([], new Set(), false)).toBe("none"); }); it("is 'hidden' when calendars exist but none is checked", () => { const calendars = [makeCalendar({ id: "cal-1", access: "owner", isOwner: true })]; - expect(calendarPickerEmptyReason(calendars, new Set())).toBe("hidden"); + expect(calendarPickerEmptyReason(calendars, new Set(), false)).toBe("hidden"); }); it("is 'hidden' even when the unchecked calendars are all unwritable", () => { const calendars = [makeCalendar({ id: "cal-1", access: "viewer", isOwner: false })]; - expect(calendarPickerEmptyReason(calendars, new Set())).toBe("hidden"); + expect(calendarPickerEmptyReason(calendars, new Set(), false)).toBe("hidden"); }); it("is 'unwritable' when checked calendars exist but none is writable", () => { @@ -327,6 +327,34 @@ describe("calendarPickerEmptyReason", () => { sourceUrl: "https://example.com/feed.ics", }), ]; - expect(calendarPickerEmptyReason(calendars, new Set(["cal-1", "cal-2"]))).toBe("unwritable"); + expect(calendarPickerEmptyReason(calendars, new Set(["cal-1", "cal-2"]), false)).toBe("unwritable"); + }); + + // #305, ADR-0082: a second way of not seeing a Calendar — fires only when + // the caller passes the Active Calendar Set's own (already narrowed to + // empty) membership, distinct from "none" which means no Calendars exist + // anywhere. + it("is 'outOfSet' when the Active Calendar Set's own membership is empty", () => { + expect(calendarPickerEmptyReason([], new Set(), true)).toBe("outOfSet"); + }); + + // The existing hidden/unwritable precedence is unchanged inside a + // non-empty Set — same three cases as above, just evaluated over the + // Set's members (still non-empty) instead of every Calendar. + it("is 'hidden', not 'outOfSet', when a non-empty Set's members exist but none is checked", () => { + const calendars = [makeCalendar({ id: "cal-1", access: "owner", isOwner: true })]; + expect(calendarPickerEmptyReason(calendars, new Set(), true)).toBe("hidden"); + }); + + it("is 'unwritable', not 'outOfSet', when a non-empty Set holds only read-only Calendars that are checked", () => { + const calendars = [ + makeCalendar({ + id: "cal-1", + access: "viewer", + isOwner: true, + sourceUrl: "https://example.com/feed.ics", + }), + ]; + expect(calendarPickerEmptyReason(calendars, new Set(["cal-1"]), true)).toBe("unwritable"); }); }); diff --git a/src/lib/calendar.ts b/src/lib/calendar.ts index c19aab3..bf360a1 100644 --- a/src/lib/calendar.ts +++ b/src/lib/calendar.ts @@ -45,6 +45,11 @@ export interface Calendar { // Source points at, which the sidebar's per-Connection heading re-opens // the Calendar picker with. connectionId?: number; + // exposed is the caller's own resolved Exposure answer (#297, ADR-0080): + // whether this Calendar appears in their own CalDAV home-set. Defaults to + // true for every Calendar except a Linked Calendar for its own Owner, who + // defaults to false — overridable either way, independently per caller. + exposed?: boolean; } // isBrokenSubscription reports whether calendar's last Refresh failed — @@ -78,9 +83,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"; } @@ -162,23 +170,36 @@ export function shareCountTooltip(shareCount: number): string { } // CalendarPickerEmptyReason explains why the Event modal's Calendar picker -// has no options to offer (#174) — the remedy differs by cause, so the +// has no options to offer (#174, #305) — the remedy differs by cause, so the // empty state must distinguish them rather than showing one generic // message: "none" means create one; "hidden" means show one already owned, // which must never suggest creating a Calendar the caller already has; -// "unwritable" means every checked Calendar is Subscribed or Viewer-only. -// "hidden" wins over "unwritable" — a Calendar that's both unchecked and -// unwritable is still reachable by checking it. Meaningful only when the -// picker's own options (checked Calendars filtered through -// canWriteCalendarEvents, same as here) are already empty — the caller -// establishes that before asking why; this just distinguishes the causes. -export type CalendarPickerEmptyReason = "none" | "hidden" | "unwritable"; +// "unwritable" means every checked Calendar is Subscribed or Viewer-only; +// "outOfSet" means the Active Calendar Set itself has nothing in it (#305, +// ADR-0082), whose remedy is switching to All calendars rather than either +// of the above. "hidden" wins over "unwritable" — a Calendar that's both +// unchecked and unwritable is still reachable by checking it. Meaningful +// only when the picker's own options (in the Active Calendar Set, checked, +// and writable — same as here) are already empty — the caller establishes +// that before asking why; this just distinguishes the causes. +export type CalendarPickerEmptyReason = "none" | "hidden" | "unwritable" | "outOfSet"; +// calendars is already narrowed to the Active Calendar Set's members when +// one is active (see inScopeCalendars) — the caller does that narrowing, +// same as it always filtered to writable/checked before asking; this +// function only distinguishes causes over whatever universe it's given. +// hasActiveCalendarSet is what tells "no Calendars anywhere" (none) apart +// from "the Active Calendar Set itself has nothing" (outOfSet) when that +// narrowed universe comes back empty — a Set holding only read-only +// Calendars is neither: it's non-empty, so this falls through to the +// ordinary hidden/unwritable precedence exactly as an unscoped caller would +// (ADR-0082's "existing precedence, unchanged, over the Set's members"). export function calendarPickerEmptyReason( calendars: Calendar[], checkedCalendarIds: Set<string>, + hasActiveCalendarSet: boolean, ): CalendarPickerEmptyReason { - if (calendars.length === 0) return "none"; + if (calendars.length === 0) return hasActiveCalendarSet ? "outOfSet" : "none"; if (getCheckedCalendars(calendars, checkedCalendarIds).length === 0) return "hidden"; return "unwritable"; } 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<number | typeof UNRESOLVED_CONNECTION, Calendar[]>; + 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<number | typeof UNRESOLVED_CONNECTION, Calendar[]>(); + 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/calendarSetScope.test.ts b/src/lib/calendarSetScope.test.ts new file mode 100644 index 0000000..af8fefc --- /dev/null +++ b/src/lib/calendarSetScope.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { inScopeCalendars } from "./calendarSetScope"; +import type { Calendar } from "./calendar"; +import type { CalendarSet } from "./calendarSetsApi"; + +function makeCalendar(id: string): Calendar { + return { id, name: id, color: "#12809CFF" }; +} + +function makeCalendarSet(id: number, calendarIds: string[]): CalendarSet { + return { id, name: "Work", calendarIds }; +} + +describe("inScopeCalendars", () => { + it("yields every Calendar when no Active Calendar Set is set", () => { + const calendars = [makeCalendar("cal-1"), makeCalendar("cal-2")]; + + expect(inScopeCalendars(calendars, null)).toEqual(calendars); + }); + + it("yields only an active Set's members", () => { + const cal1 = makeCalendar("cal-1"); + const cal2 = makeCalendar("cal-2"); + const activeSet = makeCalendarSet(1, ["cal-1"]); + + expect(inScopeCalendars([cal1, cal2], activeSet)).toEqual([cal1]); + }); + + it("omits a member the caller can no longer see", () => { + const cal1 = makeCalendar("cal-1"); + // cal-revoked is still on the Set's membership list, but has dropped out + // of the caller's own Calendar list (e.g. a revoked Share) — no cascade + // has run yet, or the client just hasn't refetched. + const activeSet = makeCalendarSet(1, ["cal-1", "cal-revoked"]); + + expect(inScopeCalendars([cal1], activeSet)).toEqual([cal1]); + }); + + it("yields nothing for an empty Set", () => { + const calendars = [makeCalendar("cal-1"), makeCalendar("cal-2")]; + const activeSet = makeCalendarSet(1, []); + + expect(inScopeCalendars(calendars, activeSet)).toEqual([]); + }); +}); diff --git a/src/lib/calendarSetScope.ts b/src/lib/calendarSetScope.ts new file mode 100644 index 0000000..3fa0219 --- /dev/null +++ b/src/lib/calendarSetScope.ts @@ -0,0 +1,22 @@ +import type { Calendar } from "./calendar"; +import type { CalendarSet } from "./calendarSetsApi"; + +// inScopeCalendars answers the one question the whole Calendar Set feature +// reduces to (#303, ADR-0082): which Calendars exist right now. No Active +// Calendar Set (activeCalendarSet is null — "All calendars") answers with +// every Calendar, exactly as the app behaves today. An active one narrows +// to its members, filtered through the caller's own current Calendar list — +// so a member the caller can no longer see (a revoked Share, an +// unsubscribe, a disconnected Connection) is silently absent rather than +// erroring, and an empty Set answers with nothing. The sidebar and the grid +// compose over this one answer rather than each re-deriving it, which is +// what keeps both of them ignorant of Sets and makes the rule testable +// once. +export function inScopeCalendars( + calendars: Calendar[], + activeCalendarSet: CalendarSet | null, +): Calendar[] { + if (!activeCalendarSet) return calendars; + const memberIds = new Set(activeCalendarSet.calendarIds); + return calendars.filter((calendar) => memberIds.has(calendar.id)); +} diff --git a/src/lib/calendarSetsApi.ts b/src/lib/calendarSetsApi.ts new file mode 100644 index 0000000..faf8a59 --- /dev/null +++ b/src/lib/calendarSetsApi.ts @@ -0,0 +1,87 @@ +import { authedFetch, errorFromResponse } from "./apiClient"; +import { workspaceHeaders } from "./workspaceHeaders"; + +// 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 = { + // Every Calendar Set the caller owns in the active Workspace. + async list(accessToken: string): Promise<CalendarSet[]> { + 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<CalendarSet> { + 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<CalendarSet> { + 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<void> { + const response = await authedFetch(accessToken, `/api/calendar-sets/${id}`, { + method: "DELETE", + credentials: "include", + headers: workspaceHeaders(), + }); + 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<void> { + 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<void> { + 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.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<typeof import("./calendarSetsApi")>("./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 new file mode 100644 index 0000000..721be7c --- /dev/null +++ b/src/lib/calendarSetsStore.ts @@ -0,0 +1,96 @@ +import { create } from "zustand"; +import { useAuthStore } from "./authStore"; +import { type CalendarSet, calendarSetsApi } from "./calendarSetsApi"; +import { useShellStore } from "./shellStore"; + +// 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<void>; + createCalendarSet: (name: string) => Promise<CalendarSet>; + renameCalendarSet: (id: number, name: string) => Promise<void>; + deleteCalendarSet: (id: number) => Promise<void>; + addCalendarToSet: (id: number, calendarId: string) => Promise<void>; + removeCalendarFromSet: (id: number, calendarId: string) => Promise<void>; +} + +function requireAccessToken(): string { + const accessToken = useAuthStore.getState().accessToken; + if (!accessToken) throw new Error("Not authenticated."); + return accessToken; +} + +export const useCalendarSetsStore = create<CalendarSetsState>((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) }); + // 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 + // 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, + ), + }); + }, +})); + +// useActiveCalendarSet resolves shellStore's activeCalendarSetId against +// this store's own list (#303, ADR-0082) — the one place that lookup +// happens, shared by the sidebar, the grid, and the top-bar switcher. +// Resolves to null both for "All calendars" and for an id this store +// doesn't (yet, or any longer) carry, so a stale id left over from a +// Workspace or Set the caller has since lost degrades to "All calendars" +// rather than throwing. +export function useActiveCalendarSet(): CalendarSet | null { + const activeCalendarSetId = useShellStore((state) => state.activeCalendarSetId); + const calendarSets = useCalendarSetsStore((state) => state.calendarSets); + return calendarSets.find((calendarSet) => calendarSet.id === activeCalendarSetId) ?? null; +} diff --git a/src/lib/calendarsApi.ts b/src/lib/calendarsApi.ts index 3426010..338bd5e 100644 --- a/src/lib/calendarsApi.ts +++ b/src/lib/calendarsApi.ts @@ -141,6 +141,23 @@ export const calendarsApi = { return (await response.json()) as Calendar; }, + // setExposure sets the caller's own Exposure choice on id (#297, + // ADR-0080): whether id appears in their own CalDAV home-set. Its own + // endpoint, unlike updateColor's reuse of the general PATCH: Exposure has + // no "the Calendar's own" value for an Owner's write to land on instead, + // so every caller — Owner included — always writes their own row here. + async setExposure(accessToken: string, id: string, exposed: boolean): Promise<boolean> { + 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<void> { 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<void>; + // 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<void>; // 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<CalendarsState>((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/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<void> { + const created = await useTaskListsStore.getState().createTaskList(name, color); + useShellStore.getState().addCheckedTaskListId(created.id); +} diff --git a/src/lib/event.ts b/src/lib/event.ts index dade37b..206932a 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -33,6 +33,11 @@ export interface Event { // the exclusive next day) and are wall-clock dates, never // timezone-converted. See ADR-0017 and CONTEXT.md's All-day Event. allDay?: boolean; + // Whether this Event consumes the time it occupies — iCalendar's own + // TRANSP. Absent means Busy, matching RFC 5545's own default; a timed + // Event is created Busy, an all-day one Free (ADR-0086). Only a Busy + // Event will later close a Booking Link's slot. + busy?: boolean; // The iCalendar RRULE (RFC 5545) describing how this Event repeats, or // undefined when it does not recur. The Event's start/end define the first // Occurrence and the duration every Occurrence inherits. See ADR-0016. diff --git a/src/lib/eventDisclosure.test.ts b/src/lib/eventDisclosure.test.ts index 1c96198..0ec2885 100644 --- a/src/lib/eventDisclosure.test.ts +++ b/src/lib/eventDisclosure.test.ts @@ -10,6 +10,7 @@ function signals(overrides: Partial<EventDisclosureSignals> = {}): EventDisclosu reminderCount: 0, attachmentCount: 0, attendeeCount: 0, + busyDiffersFromDefault: false, ...overrides, }; } @@ -52,4 +53,8 @@ describe("hasSecondaryEventFields", () => { it("is true when there is at least one attendee", () => { expect(hasSecondaryEventFields(signals({ attendeeCount: 1 }))).toBe(true); }); + + it("is true when Busy differs from its all-day-based default (ADR-0086)", () => { + expect(hasSecondaryEventFields(signals({ busyDiffersFromDefault: true }))).toBe(true); + }); }); diff --git a/src/lib/eventDisclosure.ts b/src/lib/eventDisclosure.ts index 5823561..2998366 100644 --- a/src/lib/eventDisclosure.ts +++ b/src/lib/eventDisclosure.ts @@ -19,6 +19,11 @@ export interface EventDisclosureSignals { reminderCount: number; attachmentCount: number; attendeeCount: number; + // Whether the Event being opened carries a Busy value other than its + // all-day-ness' own default (Busy for timed, Free for all-day) — + // ADR-0086's control lives behind More options, so it should only pull + // the modal open when it's worth looking at. + busyDiffersFromDefault: boolean; } export function hasSecondaryEventFields(signals: EventDisclosureSignals): boolean { @@ -29,6 +34,7 @@ export function hasSecondaryEventFields(signals: EventDisclosureSignals): boolea signals.hasRrule || signals.reminderCount > 0 || signals.attachmentCount > 0 || - signals.attendeeCount > 0 + signals.attendeeCount > 0 || + signals.busyDiffersFromDefault ); } diff --git a/src/lib/eventsApi.test.ts b/src/lib/eventsApi.test.ts index 4d58158..4f97dab 100644 --- a/src/lib/eventsApi.test.ts +++ b/src/lib/eventsApi.test.ts @@ -126,6 +126,7 @@ describe("eventsApi.create", () => { start: "2026-01-01T09:00:00.000Z", end: "2026-01-01T10:00:00.000Z", allDay: false, + busy: true, rrule: "", }), }), @@ -324,6 +325,7 @@ describe("eventsApi.update", () => { start: "2026-01-01T09:00:00.000Z", end: "2026-01-01T10:00:00.000Z", allDay: false, + busy: true, rrule: "", }), }), diff --git a/src/lib/eventsApi.ts b/src/lib/eventsApi.ts index 011251a..3486e4f 100644 --- a/src/lib/eventsApi.ts +++ b/src/lib/eventsApi.ts @@ -10,6 +10,9 @@ interface EventWire { end: string; // Absent (omitted by the backend) for a non-recurring event. allDay?: boolean; + // Never omitted by the backend (ADR-0086) — Free (false) is a meaningful, + // common value on an all-day Event. + busy: boolean; rrule?: string; // Present only on an Override (ADR-0016). parentId?: string; @@ -100,6 +103,7 @@ function fromWire(wire: EventWire): Event { start: parseEventTime(wire.start, wire.allDay), end: parseEventTime(wire.end, wire.allDay), allDay: wire.allDay || undefined, + busy: wire.busy, rrule: wire.rrule || undefined, parentId: wire.parentId, recurrenceId: wire.recurrenceId ? new Date(wire.recurrenceId) : undefined, @@ -142,6 +146,11 @@ export const eventsApi = { start: Date; end: Date; allDay?: boolean; + // Whether this Event consumes the time it occupies (ADR-0086). + // Undefined defaults to Busy on the wire, matching RFC 5545's own + // default — the caller (the Event modal) always decides and sends a + // concrete value for a fresh create. + busy?: boolean; rrule?: string; parentId?: string; recurrenceId?: Date; @@ -178,6 +187,9 @@ export const eventsApi = { start: serializeEventTime(event.start, event.allDay), end: serializeEventTime(event.end, event.allDay), allDay: event.allDay ?? false, + // Defaults to Busy (never Free) when the caller leaves it unset, + // matching RFC 5545's own TRANSP default (ADR-0086). + busy: event.busy ?? true, rrule: event.rrule ?? "", parentId: event.parentId, recurrenceId: event.recurrenceId?.toISOString(), @@ -206,6 +218,8 @@ export const eventsApi = { start: Date; end: Date; allDay?: boolean; + // Mirrors create's own busy (ADR-0086). + busy?: boolean; rrule?: string; tzid?: string; description?: string; @@ -224,6 +238,7 @@ export const eventsApi = { start: serializeEventTime(changes.start, changes.allDay), end: serializeEventTime(changes.end, changes.allDay), allDay: changes.allDay ?? false, + busy: changes.busy ?? true, rrule: changes.rrule ?? "", tzid: changes.tzid, description: changes.description, diff --git a/src/lib/eventsStore.ts b/src/lib/eventsStore.ts index 2e21817..413fe85 100644 --- a/src/lib/eventsStore.ts +++ b/src/lib/eventsStore.ts @@ -178,6 +178,7 @@ export const useEventsStore = create<EventsState>((set, get) => ({ start: updated.start, end: updated.end, allDay: updated.allDay, + busy: updated.busy, rrule: updated.rrule, tzid: updated.tzid, description: updated.description, diff --git a/src/lib/gridTime.ts b/src/lib/gridTime.ts index 8ac04e8..31955f3 100644 --- a/src/lib/gridTime.ts +++ b/src/lib/gridTime.ts @@ -38,7 +38,7 @@ function roundToIncrement( return new Date(roundedMs); } -function snapToIncrement(date: Date, incrementMinutes: number): Date { +export function snapToIncrement(date: Date, incrementMinutes: number): Date { return roundToIncrement(date, incrementMinutes, Math.round); } 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 }); diff --git a/src/lib/publicBookingApi.ts b/src/lib/publicBookingApi.ts new file mode 100644 index 0000000..ef31243 --- /dev/null +++ b/src/lib/publicBookingApi.ts @@ -0,0 +1,108 @@ +import { ApiError, errorFromResponse } from "./apiClient"; + +// The public Booking Link page's own API surface (#324, ADR-0084, +// ADR-0087): no Session, no Access token, no Workspace header — every call +// here goes straight through fetch, unlike every other *Api module, which +// routes through authedFetch. + +export interface PublicBookingLink { + hostName: string; + hostTimezone: string; + title: string; + durationMinutes: number; + location: string; + description: string; + bookingHorizonDays: number; + // Paused collapses "explicitly Paused", "no SMTP configured on this + // instance" and "the host lost Access to the Book-into Calendar" into one + // answer (ADR-0087) — the page has one message for all three, and no way + // to tell them apart. + paused: boolean; +} + +export interface BookingConfirmation { + start: Date; + end: Date; +} + +export const publicBookingApi = { + // GET /api/public/{handle}/{slug}: host and offer details, regardless of + // whether the link is Public or Private (ADR-0084's "a Private link is + // reachable by its direct URL") — a 404 here (ApiError with status 404) + // means an unknown Handle, an unknown Slug or a reserved-word Handle, + // deliberately indistinguishable from one another. + async get(handle: string, slug: string): Promise<PublicBookingLink> { + const response = await fetch(`/api/public/${encodeURIComponent(handle)}/${encodeURIComponent(slug)}`); + if (!response.ok) throw await errorFromResponse(response); + return (await response.json()) as PublicBookingLink; + }, + + // GET /api/public/{handle}/{slug}/slots?year=&month=: every bookable slot + // start within the given calendar month (month is 1-12), derived + // server-side (#323) so this page never re-derives availability itself. A + // Paused link answers an empty list rather than an error. + async slots(handle: string, slug: string, year: number, month: number): Promise<Date[]> { + const response = await fetch( + `/api/public/${encodeURIComponent(handle)}/${encodeURIComponent(slug)}/slots?year=${year}&month=${month}`, + ); + if (!response.ok) throw await errorFromResponse(response); + const body = (await response.json()) as { slots: string[] }; + return body.slots.map((slot) => new Date(slot)); + }, + + // POST /api/public/{handle}/{slug}/book: confirms start into a Busy Event + // on the host's Book-into Calendar (#326, ADR-0087). visitorName and + // visitorEmail are the booking form's only two fields, both required. A + // slot no longer bookable — raced by another visitor, or simply stale — + // answers 409 slot_taken (isSlotTakenError below); the caller re-fetches + // slots() and asks the visitor to pick again. + async book(handle: string, slug: string, start: Date, visitorName: string, visitorEmail: string): Promise<BookingConfirmation> { + const response = await fetch(`/api/public/${encodeURIComponent(handle)}/${encodeURIComponent(slug)}/book`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ start: start.toISOString(), visitorName, visitorEmail }), + }); + if (!response.ok) throw await errorFromResponse(response); + const body = (await response.json()) as { start: string; end: string }; + return { start: new Date(body.start), end: new Date(body.end) }; + }, + + // POST /api/public/cancel-booking: the signed cancel link's own + // destination (#327, ADR-0087). Deletes the booking's Event and frees the + // slot immediately; idempotent, so following the link twice never errors + // on the second attempt. + async cancel(token: string): Promise<void> { + const response = await fetch(`/api/public/cancel-booking`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + if (!response.ok) throw await errorFromResponse(response); + }, +}; + +export function isNotFoundError(error: unknown): boolean { + return error instanceof ApiError && error.status === 404; +} + +// isSlotTakenError reports whether error is book's own 409 for a slot that +// was just taken — by another visitor racing the same one, or because it +// simply isn't bookable any more (#326, ADR-0087). +export function isSlotTakenError(error: unknown): boolean { + return error instanceof ApiError && error.code === "slot_taken"; +} + +// isBookingAlreadyStartedError reports whether error is cancel's own 409 +// for a booking whose own start has already passed (#327, ADR-0087) — a +// stale link cannot delete something that already happened. +export function isBookingAlreadyStartedError(error: unknown): boolean { + return error instanceof ApiError && error.code === "booking_already_started"; +} + +// isInvalidCancelTokenError reports whether error is cancel's own 400 for a +// token that doesn't parse or verify (#327, ADR-0087) — the only other +// client error cancel() can answer besides isBookingAlreadyStartedError +// above, so a plain status check is unambiguous here. +export function isInvalidCancelTokenError(error: unknown): boolean { + return error instanceof ApiError && error.status === 400; +} diff --git a/src/lib/publicBookingSlots.test.ts b/src/lib/publicBookingSlots.test.ts new file mode 100644 index 0000000..497b2d1 --- /dev/null +++ b/src/lib/publicBookingSlots.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { daysWithSlots, formatSlotTime, slotsForDay, zonedDayKey } from "./publicBookingSlots"; + +describe("zonedDayKey", () => { + it("keys an instant by its calendar date in the given zone", () => { + // 2026-08-20 23:30 UTC is still 2026-08-20 in New York (UTC-4) but + // already 2026-08-21 in Berlin (UTC+2) — the whole reason this can't be + // a bare date-fns format() over the raw Date. + const instant = new Date("2026-08-20T23:30:00Z"); + expect(zonedDayKey(instant, "America/New_York")).toBe("2026-08-20"); + expect(zonedDayKey(instant, "Europe/Berlin")).toBe("2026-08-21"); + }); +}); + +describe("daysWithSlots", () => { + it("collapses multiple slots on the same zoned day into one key", () => { + const slots = [new Date("2026-08-20T13:00:00Z"), new Date("2026-08-20T14:00:00Z"), new Date("2026-08-21T13:00:00Z")]; + expect(daysWithSlots(slots, "Etc/UTC")).toEqual(new Set(["2026-08-20", "2026-08-21"])); + }); + + it("marks a day by its local date in zone, not the slot's UTC date", () => { + // 22:00 UTC on the 20th is already the 21st in Tokyo (UTC+9). + const slots = [new Date("2026-08-20T22:00:00Z")]; + expect(daysWithSlots(slots, "Asia/Tokyo")).toEqual(new Set(["2026-08-21"])); + }); +}); + +describe("slotsForDay", () => { + const slots = [ + new Date("2026-08-20T09:00:00Z"), + new Date("2026-08-20T13:00:00Z"), + new Date("2026-08-21T09:00:00Z"), + ]; + + it("returns only the slots on day, sorted chronologically", () => { + const day = new Date("2026-08-20T00:00:00Z"); + const result = slotsForDay(slots, day, "Etc/UTC"); + expect(result).toEqual([slots[0], slots[1]]); + }); + + it("compares day in the given zone rather than UTC", () => { + // 09:00 UTC on the 20th is already 2026-08-20 18:00 in Tokyo — still the + // 20th there too, but 13:00 UTC is 22:00 Tokyo, still the 20th; the + // 21st's 09:00 UTC slot is 18:00 Tokyo on the 21st, so it's excluded. + const day = new Date("2026-08-20T00:00:00Z"); + const result = slotsForDay(slots, day, "Asia/Tokyo"); + expect(result).toEqual([slots[0], slots[1]]); + }); +}); + +describe("formatSlotTime", () => { + const slot = new Date("2026-08-20T18:47:00Z"); + + it("formats in zone rather than UTC, 12-hour", () => { + expect(formatSlotTime(slot, "America/New_York", "12h")).toBe("2:47 PM"); + }); + + it("formats in zone rather than UTC, 24-hour", () => { + expect(formatSlotTime(slot, "Europe/Berlin", "24h")).toBe("20:47"); + }); +}); diff --git a/src/lib/publicBookingSlots.ts b/src/lib/publicBookingSlots.ts new file mode 100644 index 0000000..ff89521 --- /dev/null +++ b/src/lib/publicBookingSlots.ts @@ -0,0 +1,45 @@ +import { toZonedTime } from "date-fns-tz"; +import { format } from "date-fns"; +import type { TimeFormat } from "./authApi"; +import { timePattern } from "./timeFormat"; + +// The public Booking Link page's own shared derivation (#324, ADR-0085, +// ADR-0087): zone conversion, day grouping and time formatting, kept out of +// the page component itself so the month grid's "which days have slots" and +// the slot list's "what time is this, in whose zone" logic is unit-testable +// on its own — slots themselves come from the backend (#323); this module +// only ever reshapes what it already received. + +// A slot instant's calendar date in zone, e.g. "2026-08-20" — the visitor's +// own local day, not the host's Anchor zone (ADR-0085's Schedule zone) and +// not the browser's own zone unless zone happens to equal it. Used both to +// mark a day available on the month grid and to filter the slot list down +// to the selected day. +export function zonedDayKey(slot: Date, zone: string): string { + return format(toZonedTime(slot, zone), "yyyy-MM-dd"); +} + +// Every day key (zonedDayKey) that has at least one slot, in zone — the +// month grid's "available" marker (#324's "A month grid marks days with +// slots as available and days without as unavailable"). +export function daysWithSlots(slots: Date[], zone: string): Set<string> { + const days = new Set<string>(); + for (const slot of slots) { + days.add(zonedDayKey(slot, zone)); + } + return days; +} + +// slots that fall on day (compared in zone), sorted chronologically — the +// slot list beside the grid for whichever day is selected. +export function slotsForDay(slots: Date[], day: Date, zone: string): Date[] { + const key = zonedDayKey(day, zone); + return slots.filter((slot) => zonedDayKey(slot, zone) === key).sort((a, b) => a.getTime() - b.getTime()); +} + +// A slot's time-of-day in zone, formatted per the visitor's 12h/24h choice — +// never the slot's UTC or host-zone time, since the whole point of the +// picker is showing the visitor their own wall-clock (ADR-0085). +export function formatSlotTime(slot: Date, zone: string, timeFmt: TimeFormat): string { + return format(toZonedTime(slot, zone), timePattern(timeFmt)); +} diff --git a/src/lib/publicIndexApi.ts b/src/lib/publicIndexApi.ts new file mode 100644 index 0000000..a347ceb --- /dev/null +++ b/src/lib/publicIndexApi.ts @@ -0,0 +1,31 @@ +import { errorFromResponse } from "./apiClient"; + +// The public index page's own API surface (#325, ADR-0084): no Session, no +// Access token, no Workspace header — a plain fetch, the same posture +// publicBookingApi takes toward /:handle/:slug. + +export interface PublicIndexLink { + slug: string; + title: string; + durationMinutes: number; +} + +export interface PublicIndex { + // Empty for an unknown Handle, a reserved-word Handle, and a Handle whose + // links are all Private, Paused, or otherwise unreachable (ADR-0087) — + // deliberately indistinguishable from one another (ADR-0084), so this page + // never renders a name unless links is non-empty. + hostName: string; + links: PublicIndexLink[]; +} + +export const publicIndexApi = { + // GET /api/public/{handle}: the owner's Name and every Public Booking + // Link they hold, unioned across every Workspace they belong to. Always + // 200 — there is no not-found case here, only an index with no links. + async get(handle: string): Promise<PublicIndex> { + const response = await fetch(`/api/public/${encodeURIComponent(handle)}`); + if (!response.ok) throw await errorFromResponse(response); + return (await response.json()) as PublicIndex; + }, +}; 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<T>( + knownIds: ReadonlySet<T>, + checkedIds: ReadonlySet<T>, + freshIds: Iterable<T>, +): { checkedIds: Set<T>; knownIds: Set<T> } { + 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/recurrenceScope.test.ts b/src/lib/recurrenceScope.test.ts index 36b1271..1330dea 100644 --- a/src/lib/recurrenceScope.test.ts +++ b/src/lib/recurrenceScope.test.ts @@ -4,10 +4,12 @@ import { describe, expect, it } from "vitest"; import type { Event } from "./event"; import { toFloating, viewerZone } from "./floatingTime"; import { + defaultBusyForAllDay, hasFieldChanges, makeException, makeOverride, remindersEqual, + resolveBusy, resolveColor, shouldDiscardChildren, splitFollowing, @@ -51,6 +53,33 @@ describe("makeOverride", () => { expect(makeOverride(coloredMaster, occurrenceStart, changes).color).toBe("#12809CFF"); }); + it("starts a fresh Override's Busy as a copy of the master's, independent once set (ADR-0086)", () => { + const freeMaster: Event = { ...master, busy: false }; + const occurrenceStart = new Date(2026, 0, 3, 9, 0); + const changes = { + calendarId: "cal-1", + title: "Standup", + start: occurrenceStart, + end: new Date(2026, 0, 3, 9, 30), + }; + + expect(makeOverride(freeMaster, occurrenceStart, changes).busy).toBe(false); + }); + + it("uses an explicit Busy override rather than the master's own", () => { + const busyMaster: Event = { ...master, busy: true }; + const occurrenceStart = new Date(2026, 0, 3, 9, 0); + const changes = { + calendarId: "cal-1", + title: "Standup", + start: occurrenceStart, + end: new Date(2026, 0, 3, 9, 30), + busy: false, + }; + + expect(makeOverride(busyMaster, occurrenceStart, changes).busy).toBe(false); + }); + it("starts a fresh Override's URL as a copy of the master's, independent once set (ADR-0063)", () => { const linkedMaster: Event = { ...master, url: "https://master.example/link" }; const occurrenceStart = new Date(2026, 0, 3, 9, 0); @@ -253,6 +282,21 @@ describe("splitFollowing", () => { expect(result.newMaster.rrule).toBe("FREQ=WEEKLY;BYDAY=TU,TH"); }); + it("carries the old master's Busy into the new master when the edit doesn't touch it (ADR-0086)", () => { + const freeMaster: Event = { ...master, busy: false }; + const splitStart = new Date(2026, 0, 3, 9, 0); + const changes = { + calendarId: "cal-1", + title: "Standup", + start: splitStart, + end: new Date(2026, 0, 3, 9, 30), + }; + + const result = splitFollowing(freeMaster, splitStart, changes); + + expect(result.newMaster.busy).toBe(false); + }); + // The new Master's Reminders are copied from the old Master server-side // (ADR-0064, via copyRemindersFrom) — splitFollowing itself never touches // them. @@ -322,6 +366,28 @@ describe("resolveColor", () => { }); }); +describe("defaultBusyForAllDay", () => { + it("is Busy for a timed Event", () => { + expect(defaultBusyForAllDay(false)).toBe(true); + }); + + it("is Free for an all-day Event (ADR-0086)", () => { + expect(defaultBusyForAllDay(true)).toBe(false); + }); +}); + +describe("resolveBusy", () => { + const freeMaster: Event = { ...master, busy: false }; + + it("falls back to the reference's own Busy when the edit didn't touch it (ADR-0086)", () => { + expect(resolveBusy({}, freeMaster)).toBe(false); + }); + + it("sets Busy when the edit provides one", () => { + expect(resolveBusy({ busy: true }, freeMaster)).toBe(true); + }); +}); + describe("hasFieldChanges", () => { const original = { calendarId: "cal-1", @@ -447,6 +513,17 @@ describe("hasFieldChanges", () => { false, ); }); + + it("is true when Busy differs (ADR-0086)", () => { + expect( + hasFieldChanges( + { ...original, busy: true }, + { ...original, busy: false }, + originalReminders, + originalReminders, + ), + ).toBe(true); + }); }); describe("remindersEqual", () => { diff --git a/src/lib/recurrenceScope.ts b/src/lib/recurrenceScope.ts index 2a3b008..90a3490 100644 --- a/src/lib/recurrenceScope.ts +++ b/src/lib/recurrenceScope.ts @@ -19,6 +19,10 @@ export interface EventFieldChanges { // Absent from a drag-only edit (allDay isn't draggable yet); the caller // falls back to the master's own allDay in that case. allDay?: boolean; + // Absent from a drag-only edit, like allDay; the caller falls back to the + // reference Event's own Busy (ADR-0086). No third "reset" state — unlike + // color, an Event's own Busy has nothing to inherit from. + busy?: boolean; // The Anchor zone: preserved from whatever Event this change derives from // — no picker exists to change it explicitly yet (ADR-0019). tzid?: string; @@ -57,6 +61,25 @@ export function resolveAllDay( return changes.allDay ?? master.allDay; } +/** The Busy value a brand-new Event gets from its own all-day-ness alone, + * before anything else — Busy for timed, Free for all-day (ADR-0086). Named + * so the Event modal's own live default (applied as the create form is + * filled in, until the User overrides Busy directly) states the rule once + * rather than re-deriving `!allDay` at each call site. */ +export function defaultBusyForAllDay(allDay: boolean): boolean { + return !allDay; +} + +/** `changes`' busy, falling back to `reference`'s own when the edit didn't + * touch it (a drag-only edit, which can't yet change Busy) — mirrors + * resolveAllDay (ADR-0086). */ +export function resolveBusy( + changes: Pick<EventFieldChanges, "busy">, + reference: Pick<Event, "busy">, +): boolean | undefined { + return changes.busy ?? reference.busy; +} + /** `changes`' description, falling back to `reference`'s own when the edit * didn't touch it (a drag/resize-only edit), so a scoped drag on a recurring * Occurrence can't silently wipe it. */ @@ -118,6 +141,9 @@ export function makeOverride( start: changes.start, end: changes.end, allDay: resolveAllDay(changes, master), + // A fresh Override starts as a copy of the Master's Busy, same as + // color — settable independently once created (ADR-0086). + busy: resolveBusy(changes, master), // An Override inherits its master's Anchor zone — only an explicit zone // choice would change it, and no picker exists yet (ADR-0019). tzid: master.tzid, @@ -219,6 +245,7 @@ export function splitFollowing( newMaster: { ...changes, allDay: resolveAllDay(changes, master), + busy: resolveBusy(changes, master), rrule: master.rrule, tzid: master.tzid, description: resolveDescription(changes, master), @@ -291,6 +318,7 @@ export function hasFieldChanges( changes.start.getTime() !== original.start.getTime() || changes.end.getTime() !== original.end.getTime() || Boolean(changes.allDay) !== Boolean(original.allDay) || + Boolean(changes.busy) !== Boolean(original.busy) || (changes.description ?? "") !== (original.description ?? "") || (changes.location ?? "") !== (original.location ?? "") || (changes.url ?? "") !== (original.url ?? "") || diff --git a/src/lib/savePlan.test.ts b/src/lib/savePlan.test.ts index 5b2fbca..badf545 100644 --- a/src/lib/savePlan.test.ts +++ b/src/lib/savePlan.test.ts @@ -169,6 +169,7 @@ describe("planEventSave", () => { start: START, end: END, allDay: undefined, + busy: true, rrule: undefined, tzid: "Europe/Berlin", description: undefined, diff --git a/src/lib/savePlan.ts b/src/lib/savePlan.ts index 6822ebb..a7336c2 100644 --- a/src/lib/savePlan.ts +++ b/src/lib/savePlan.ts @@ -264,6 +264,9 @@ export function planEventSave( start: input.changes.start, end: input.changes.end, allDay: input.changes.allDay, + // The form always supplies a concrete value; the RFC-matching default + // (Busy) only guards a caller that somehow doesn't (ADR-0086). + busy: input.changes.busy ?? true, rrule: input.changes.rrule, tzid: input.changes.allDay ? undefined : resolveViewerZone(), description: input.changes.description, diff --git a/src/lib/seriesOperation.test.ts b/src/lib/seriesOperation.test.ts index 2ac4dd4..3be70b7 100644 --- a/src/lib/seriesOperation.test.ts +++ b/src/lib/seriesOperation.test.ts @@ -142,6 +142,58 @@ describe("planEditOccurrence", () => { expect(ops[0]).toMatchObject({ fields: { color: "#12809CFF" } }); }); + it('scope "this": a fresh override inherits the master\'s Busy when untouched (ADR-0086)', () => { + const freeMaster = { ...recurringMaster, busy: false }; + const occurrenceStart = new Date("2026-01-03T09:00:00Z"); + const { ops } = planEditOccurrence( + { + master: freeMaster, + occurrence: occurrenceOf(freeMaster, occurrenceStart), + isOverride: false, + originalStart: occurrenceStart, + scope: "this", + changes: { + calendarId: "cal-1", + title: "Standup (moved)", + start: new Date("2026-01-03T10:00:00Z"), + end: new Date("2026-01-03T10:30:00Z"), + }, + }, + nextId, + ); + + expect(ops[0]).toMatchObject({ fields: { busy: false } }); + }); + + it('scope "this": an existing override keeps its own Busy when untouched (ADR-0086)', () => { + const override = { + id: "override-1", + calendarId: "cal-1", + title: "Standup (moved)", + start: new Date("2026-01-03T10:00:00Z"), + end: new Date("2026-01-03T10:30:00Z"), + parentId: "master-1", + recurrenceId: new Date("2026-01-03T09:00:00Z"), + busy: false, + }; + + const { ops } = planEditOccurrence({ + master: recurringMaster, + occurrence: occurrenceOf(override as never, override.start), + isOverride: true, + originalStart: override.recurrenceId, + scope: "this", + changes: { + calendarId: "cal-1", + title: "Renamed again", + start: override.start, + end: override.end, + }, + }); + + expect(ops[0]).toMatchObject({ fields: { busy: false } }); + }); + it('scope "all": keeps the rule and does not discard children when the rule is unchanged', () => { const { ops } = planEditOccurrence({ master: recurringMaster, @@ -373,6 +425,25 @@ describe("planEditOccurrence", () => { expect(ops[0]).toMatchObject({ fields: { color: undefined } }); }); + it('scope "all": preserves the master\'s Busy when the edit doesn\'t touch it (ADR-0086)', () => { + const freeMaster = { ...recurringMaster, busy: false }; + const { ops } = planEditOccurrence({ + master: freeMaster, + occurrence: occurrenceOf(freeMaster, freeMaster.start), + isOverride: false, + originalStart: freeMaster.start, + scope: "all", + changes: { + calendarId: "cal-1", + title: "Renamed", + start: freeMaster.start, + end: freeMaster.end, + }, + }); + + expect(ops[0]).toMatchObject({ fields: { busy: false } }); + }); + it('scope "following": plans a truncated old master, a new master, and the reparent boundary', () => { const splitStart = new Date("2026-01-03T09:00:00Z"); const masterWithExdates = { diff --git a/src/lib/seriesOperation.ts b/src/lib/seriesOperation.ts index 90e5489..6671a9b 100644 --- a/src/lib/seriesOperation.ts +++ b/src/lib/seriesOperation.ts @@ -6,6 +6,7 @@ import { makeException, makeOverride, resolveAllDay, + resolveBusy, resolveColor, resolveDescription, resolveLocation, @@ -47,6 +48,7 @@ type MasterCoreFields = Pick< | "location" | "url" | "color" + | "busy" >; /** @@ -204,6 +206,7 @@ export function planEditOccurrence( start: seriesChanges.start, end: seriesChanges.end, allDay: resolveAllDay(changes, master), + busy: resolveBusy(changes, master), rrule, // Preserved unchanged — no picker exists to change it (ADR-0019). tzid: master.tzid, @@ -243,6 +246,7 @@ export function planEditOccurrence( location: master.location, url: master.url, color: master.color, + busy: master.busy, }, truncatedRrule: truncatedMaster.rrule, keptExdates, @@ -274,6 +278,7 @@ export function planEditOccurrence( start: changes.start, end: changes.end, allDay: resolveAllDay(changes, master), + busy: resolveBusy(changes, occurrence.event), tzid: occurrence.event.tzid, description: resolveDescription(changes, occurrence.event), location: resolveLocation(changes, occurrence.event), @@ -304,6 +309,7 @@ export function planEditOccurrence( start: changes.start, end: changes.end, allDay: resolveAllDay(changes, master), + busy: resolveBusy(changes, occurrence.event), // Preserved unchanged — no picker exists to change it (ADR-0019). tzid: occurrence.event.tzid, description: resolveDescription(changes, occurrence.event), @@ -333,6 +339,7 @@ export function planEditOccurrence( start: override.start, end: override.end, allDay: override.allDay, + busy: override.busy, tzid: override.tzid, description: override.description, location: override.location, @@ -402,6 +409,7 @@ export function planDeleteOccurrence({ location: master.location, url: master.url, color: master.color, + busy: master.busy, }, truncatedRrule, keptExdates, diff --git a/src/lib/shellStore.test.ts b/src/lib/shellStore.test.ts index fea4889..21d3722 100644 --- a/src/lib/shellStore.test.ts +++ b/src/lib/shellStore.test.ts @@ -5,6 +5,29 @@ 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(); + }); + + // #317, ADR-0083: the Tasks panel is closed by default. + it("defaults tasksPanelOpen to false", () => { + expect(useShellStore.getState().tasksPanelOpen).toBe(false); + }); + + // #316, ADR-0083: Group by resets to Task bucket on load rather than + // remembering the caller's last-chosen axis. + it("defaults tasksPanelAxis to taskBucket", () => { + expect(useShellStore.getState().tasksPanelAxis).toBe("taskBucket"); + }); + + // #312, ADR-0083: unlike tasksPanelOpen and showCompletedTasks, this one + // defaults on — the calendar shows what's due unless the User turns it off. + it("defaults showTasksOnCalendar to true", () => { + expect(useShellStore.getState().showTasksOnCalendar).toBe(true); + }); }); describe("reconcileCheckedCalendarIds", () => { @@ -89,3 +112,104 @@ 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); + }); +}); + +describe("setTasksPanelAxis", () => { + it("sets tasksPanelAxis", () => { + useShellStore.getState().setTasksPanelAxis("priority"); + expect(useShellStore.getState().tasksPanelAxis).toBe("priority"); + + useShellStore.getState().setTasksPanelAxis("taskList"); + expect(useShellStore.getState().tasksPanelAxis).toBe("taskList"); + + useShellStore.getState().setTasksPanelAxis("taskBucket"); + expect(useShellStore.getState().tasksPanelAxis).toBe("taskBucket"); + }); +}); + +describe("setShowTasksOnCalendar", () => { + it("sets showTasksOnCalendar", () => { + useShellStore.getState().setShowTasksOnCalendar(false); + expect(useShellStore.getState().showTasksOnCalendar).toBe(false); + + useShellStore.getState().setShowTasksOnCalendar(true); + expect(useShellStore.getState().showTasksOnCalendar).toBe(true); + }); +}); diff --git a/src/lib/shellStore.ts b/src/lib/shellStore.ts index a3a81d8..f03a34a 100644 --- a/src/lib/shellStore.ts +++ b/src/lib/shellStore.ts @@ -1,11 +1,27 @@ import { create } from "zustand"; import { useCalendarsStore } from "./calendarsStore"; +import { useTaskListsStore } from "./taskListsStore"; +import { reconcileCheckedIds } from "./reconcileCheckedIds"; export type ActiveView = "day" | "week" | "month" | "year"; +// TasksPanelAxis is the Tasks panel's "Group by" choice (#316, ADR-0083): +// which heading the panel currently arranges its Tasks under. Named around +// "axis" rather than "group" deliberately — a Group already means a set of +// Workspace Members in this codebase (CONTEXT.md), and the headings +// themselves are Task buckets, Task Lists or Priorities, never "groups". +export type TasksPanelAxis = "taskBucket" | "taskList" | "priority"; + interface ShellState { selectedDate: Date; activeView: ActiveView; + // activeCalendarSetId is the Active Calendar Set (#303, ADR-0082): null + // means "All calendars", the absence of a Set rather than a row of its + // own. Session state like selectedDate and activeView, but with no + // reconcile behaviour of its own and no effect on checkedCalendarIds — + // narrowing what exists and narrowing what's toggled on are kept + // orthogonal. See inScopeCalendars. + activeCalendarSetId: number | null; checkedCalendarIds: Set<string>; // knownCalendarIds is the Calendar ids seen as of the last reconcile — the // record that lets it tell "wasn't there last time" (auto-check) apart @@ -15,6 +31,30 @@ 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<string>; + // 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; + // tasksPanelAxis is the panel's chosen Group by axis (#316, ADR-0083): + // session state like tasksPanelOpen, resetting to "taskBucket" on load + // rather than remembering the caller's last choice. + tasksPanelAxis: TasksPanelAxis; + // 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<number>; + knownTaskListIds: Set<number>; + // 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; + // showTasksOnCalendar is "Show tasks on calendar" (#312, ADR-0083): whether + // a Deadline-only Task's all-day-lane chip (and, later, a Time block on + // the hourly grid) renders at all — session state like tasksPanelOpen, but + // defaulting **on**, since the calendar is meant to show what's due unless + // the User asks to look at commitments alone. + showTasksOnCalendar: 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 @@ -24,6 +64,9 @@ interface ShellState { clearRequestedEventOpen: () => void; setSelectedDate: (date: Date) => void; setActiveView: (view: ActiveView) => void; + setActiveCalendarSetId: (id: number | null) => void; + setTasksPanelOpen: (open: boolean) => void; + setTasksPanelAxis: (axis: TasksPanelAxis) => void; setCheckedCalendarIds: (ids: Iterable<string>) => void; // reconcileCheckedCalendarIds auto-checks only ids not previously known — // e.g. a Calendar shared with the caller while the tab was in the @@ -40,33 +83,57 @@ 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<number>) => void; + toggleTaskListChecked: (id: number) => void; + addCheckedTaskListId: (id: number) => void; + setShowCompletedTasks: (show: boolean) => void; + setShowTasksOnCalendar: (show: boolean) => void; } export const useShellStore = create<ShellState>((set) => ({ selectedDate: new Date(), activeView: "week", + activeCalendarSetId: null, checkedCalendarIds: new Set( useCalendarsStore.getState().calendars.map((calendar) => calendar.id), ), knownCalendarIds: new Set( useCalendarsStore.getState().calendars.map((calendar) => calendar.id), ), + tasksPanelOpen: false, + tasksPanelAxis: "taskBucket", + // 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<number>(), + knownTaskListIds: new Set<number>(), + showCompletedTasks: false, + showTasksOnCalendar: true, 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 }), + setTasksPanelAxis: (axis) => set({ tasksPanelAxis: axis }), 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) => { @@ -89,6 +156,32 @@ export const useShellStore = create<ShellState>((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), + })), + setShowCompletedTasks: (show) => set({ showCompletedTasks: show }), + setShowTasksOnCalendar: (show) => set({ showTasksOnCalendar: show }), })); // Shared by every caller that refetches Calendars and needs the checked set @@ -104,3 +197,14 @@ export async function refetchCalendarsAndReconcile(): Promise<void> { useCalendarsStore.getState().calendars.map((calendar) => calendar.id), ); } + +// refetchTaskListsAndReconcile is refetchCalendarsAndReconcile's counterpart +// for Task Lists (#317, ADR-0083). +export async function refetchTaskListsAndReconcile(): Promise<void> { + await useTaskListsStore.getState().fetchTaskLists(); + useShellStore + .getState() + .reconcileCheckedTaskListIds( + useTaskListsStore.getState().taskLists.map((taskList) => taskList.id), + ); +} diff --git a/src/lib/taskDropIntent.test.ts b/src/lib/taskDropIntent.test.ts new file mode 100644 index 0000000..5847588 --- /dev/null +++ b/src/lib/taskDropIntent.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_TIME_BLOCK_DURATION_MINUTES, taskDropIntent, type TaskDropWrite } from "./taskDropIntent"; + +describe("taskDropIntent", () => { + it("sets a Time block at the drop time with a 1h default duration for a panel row dropped on the hourly grid (#313)", () => { + const dropTime = new Date(2026, 8, 16, 14, 30); + + const result = taskDropIntent({ kind: "panelRow" }, { surface: "hourlyGrid", time: dropTime }); + + expect(result).toEqual({ + action: "setTimeBlock", + start: dropTime, + durationMinutes: DEFAULT_TIME_BLOCK_DURATION_MINUTES, + }); + }); + + it("moves a Time block dragged elsewhere on the grid, keeping its own duration and leaving the Deadline untouched", () => { + const dropTime = new Date(2026, 8, 17, 9, 0); + + const result = taskDropIntent({ kind: "timeBlock", durationMinutes: 90 }, { surface: "hourlyGrid", time: dropTime }); + + expect(result).toEqual({ action: "setTimeBlock", start: dropTime, durationMinutes: 90 }); + expect(result).not.toHaveProperty("due"); + }); + + it("sets the Deadline and clears the Time block when a Time block is dragged into the all-day lane", () => { + const dropDate = new Date(2026, 8, 18); + + const result = taskDropIntent({ kind: "timeBlock", durationMinutes: 60 }, { surface: "allDayLane", date: dropDate }); + + expect(result).toEqual({ action: "setDeadlineAndClearTimeBlock", due: dropDate }); + }); + + it("clears the Time block and nothing else when a Time block is dragged back to the panel, leaving the Deadline untouched", () => { + const result = taskDropIntent({ kind: "timeBlock", durationMinutes: 60 }, { surface: "panel" }); + + expect(result).toEqual({ action: "clearTimeBlock" }); + expect(result).not.toHaveProperty("due"); + }); + + it("sets a Time block with a 1h default duration when an all-day chip is dragged onto the hourly grid, leaving the Deadline untouched", () => { + const dropTime = new Date(2026, 8, 19, 11, 0); + + const result = taskDropIntent({ kind: "allDayChip" }, { surface: "hourlyGrid", time: dropTime }); + + expect(result).toEqual({ + action: "setTimeBlock", + start: dropTime, + durationMinutes: DEFAULT_TIME_BLOCK_DURATION_MINUTES, + }); + expect(result).not.toHaveProperty("due"); + }); + + it("always defaults a fresh block to 60 minutes, regardless of the drop time", () => { + const morning = taskDropIntent({ kind: "panelRow" }, { surface: "hourlyGrid", time: new Date(2026, 8, 16, 6, 0) }); + const evening = taskDropIntent({ kind: "allDayChip" }, { surface: "hourlyGrid", time: new Date(2026, 8, 16, 22, 45) }); + + expect(morning).toMatchObject({ durationMinutes: 60 }); + expect(evening).toMatchObject({ durationMinutes: 60 }); + }); + + it("moves a Time block dropped on a Month Day cell, preserving the resolved time-of-day and its own duration (#315)", () => { + // The caller has already combined the target day with the block's own + // time-of-day (mirroring computeMoveToDate) before this is called — the + // resolved instant below stands in for that. + const resolvedDropTime = new Date(2026, 8, 20, 9, 0); + + const result = taskDropIntent( + { kind: "timeBlock", durationMinutes: 90 }, + { surface: "monthDay", date: resolvedDropTime }, + ); + + expect(result).toEqual({ action: "setTimeBlock", start: resolvedDropTime, durationMinutes: 90 }); + expect(result).not.toHaveProperty("due"); + }); + + it("sets the Deadline and creates no Time block when a panel row is dropped on a Month Day cell (#315)", () => { + const dropDate = new Date(2026, 8, 21); + + const result = taskDropIntent({ kind: "panelRow" }, { surface: "monthDay", date: dropDate }); + + expect(result).toEqual({ action: "setDeadlineAndClearTimeBlock", due: dropDate }); + }); + + it("sets the Deadline and creates no Time block when an all-day chip is dropped on a Month Day cell (#315)", () => { + const dropDate = new Date(2026, 8, 22); + + const result = taskDropIntent({ kind: "allDayChip" }, { surface: "monthDay", date: dropDate }); + + expect(result).toEqual({ action: "setDeadlineAndClearTimeBlock", due: dropDate }); + }); + + // A minimal model of a Task's two independent axes, updated by whichever + // write taskDropIntent returns — just enough to drive the round trip below + // without pulling in the real Task shape. + interface FakeTaskState { + start: Date | null; + durationMinutes: number | null; + due: Date | null; + } + + function applyWrite(state: FakeTaskState, write: TaskDropWrite): FakeTaskState { + switch (write.action) { + case "setTimeBlock": + return { ...state, start: write.start, durationMinutes: write.durationMinutes }; + case "setDeadlineAndClearTimeBlock": + return { start: null, durationMinutes: null, due: write.due }; + case "clearTimeBlock": + return { ...state, start: null, durationMinutes: null }; + } + } + + it("round trip panel -> grid -> all-day lane -> grid -> panel leaves the Deadline exactly as it started", () => { + // The Deadline is only ever written by the all-day-lane leg, so a round + // trip that drops back onto the same day it was already due on comes out + // exactly where it started — proof that the other three legs (the grid + // creation, the trip back onto the grid, and the trip back to the panel) + // never so much as touch `due`, matching each row's own assertion above, + // chained end to end. + const startingDue = new Date(2026, 8, 18); + let task: FakeTaskState = { start: null, durationMinutes: null, due: startingDue }; + + // panel -> grid + task = applyWrite(task, taskDropIntent({ kind: "panelRow" }, { surface: "hourlyGrid", time: new Date(2026, 8, 16, 10, 0) })); + expect(task.due).toEqual(startingDue); + + // grid -> all-day lane, dropped on the day it was already due + task = applyWrite(task, taskDropIntent({ kind: "timeBlock", durationMinutes: task.durationMinutes! }, { surface: "allDayLane", date: startingDue })); + expect(task).toEqual({ start: null, durationMinutes: null, due: startingDue }); + + // all-day lane -> grid + task = applyWrite(task, taskDropIntent({ kind: "allDayChip" }, { surface: "hourlyGrid", time: new Date(2026, 8, 17, 15, 0) })); + expect(task.due).toEqual(startingDue); + + // grid -> panel + task = applyWrite(task, taskDropIntent({ kind: "timeBlock", durationMinutes: task.durationMinutes! }, { surface: "panel" })); + + expect(task).toEqual({ start: null, durationMinutes: null, due: startingDue }); + }); +}); diff --git a/src/lib/taskDropIntent.ts b/src/lib/taskDropIntent.ts new file mode 100644 index 0000000..1552d6a --- /dev/null +++ b/src/lib/taskDropIntent.ts @@ -0,0 +1,101 @@ +/** + * Drop semantics for time-blocking a Task by drag (#313, #314, #315, + * ADR-0083): what a drag from a given source onto a given drop target + * writes. Kept out of the drag handler entirely — `gridStacking`, + * `useOccurrenceDragCommit` and `reminderDrafts` are the three pieces of + * grid logic that stayed inside components, and they are exactly the three + * with no tests. This module exists so drop semantics never join them. + * + * Three of the four targets let the target decide the write outright — the + * source only decides the duration a fresh block gets (a moved block keeps + * its own; a freshly-created one gets the 1h default), which is why + * `TaskDragSource` carries nothing else. This is what lets one `hourlyGrid` + * branch serve every one of the table's "-> hourly grid" rows (#313's + * panel-row row, #314's reschedule row, and #314's all-day-chip row) without + * a switch over the source's kind. `monthDay` is the exception (#315): Month + * has no time axis to invent an hour from, so the same drop writes a moved + * Time block when one already exists and a fresh Deadline when it doesn't — + * the one target where the source's kind decides the *action*, not just a + * duration. + */ + +/** A Task's own 1h default Time block duration (issue #309 story 48). */ +export const DEFAULT_TIME_BLOCK_DURATION_MINUTES = 60; + +/** + * What's being dragged. A panel row and an all-day chip both carry no + * existing block, so a drop of either onto the hourly grid always produces a + * fresh 1h one; a Time block carries its own current duration, which a + * reschedule preserves rather than resetting. + */ +export type TaskDragSource = + | { kind: "panelRow" } + | { kind: "allDayChip" } + | { kind: "timeBlock"; durationMinutes: number }; + +/** + * The four surfaces a Task can be dropped on. `monthDay`'s `date` is a Month + * Day cell (#315) — for a `timeBlock` source it must already carry the + * resolved time-of-day (the caller combines the target day with the block's + * own start, the same `computeMoveToDate` a Month Event drag already uses), + * since a bare day carries no hour to move to. + */ +export type TaskDropTarget = + | { surface: "hourlyGrid"; time: Date } + | { surface: "allDayLane"; date: Date } + | { surface: "panel" } + | { surface: "monthDay"; date: Date }; + +/** + * The fields a drop intent writes, as the store action it maps to rather + * than a bare field bag — `setTimeBlock`/`clearTimeBlock` never carry `due`, + * and `setDeadlineAndClearTimeBlock` is the one write that touches both axes + * at once, structurally cordoned off from the other two. A Deadline is + * therefore untouched by every row except the all-day-lane one by + * construction, not by an explicit "leave it alone" branch. + */ +export type TaskDropWrite = + | { action: "setTimeBlock"; start: Date; durationMinutes: number } + | { action: "setDeadlineAndClearTimeBlock"; due: Date } + | { action: "clearTimeBlock" }; + +/** + * What dragging `source` onto `target` writes (issue #309 stories 47-58, + * #314's drop table, #315's Month row). The full table: + * + * | Drag | Drop target | Writes | + * |-------------------|----------------|-------------------------------------| + * | Panel row | hourly grid | sets Time block, 1h default (#313) | + * | Time block | elsewhere grid | moves Time block, own duration kept | + * | All-day chip | hourly grid | sets Time block, 1h default | + * | Time block | all-day lane | sets Deadline, clears Time block | + * | Time block | panel | clears Time block | + * | Time block | Month Day cell | moves Time block, time-of-day kept | + * | Panel row/all-day | Month Day cell | sets Deadline, no Time block (#315) | + * + * The all-day-lane row is the one gesture that destroys data rather than + * adding it, and it is deliberate: precedence (`taskScheduling.ts`) would + * otherwise keep drawing the Task on the hourly grid off its stale Time + * block, and the drag would visibly snap back after having written + * something. The Month rows are deliberate in the opposite direction — + * Month can *move* a Time block but never *create* one, because inventing an + * hour from a gesture that named no hour is worse than the asymmetry with + * Week view (ADR-0083). + */ +export function taskDropIntent(source: TaskDragSource, target: TaskDropTarget): TaskDropWrite { + switch (target.surface) { + case "hourlyGrid": { + const durationMinutes = + source.kind === "timeBlock" ? source.durationMinutes : DEFAULT_TIME_BLOCK_DURATION_MINUTES; + return { action: "setTimeBlock", start: target.time, durationMinutes }; + } + case "allDayLane": + return { action: "setDeadlineAndClearTimeBlock", due: target.date }; + case "panel": + return { action: "clearTimeBlock" }; + case "monthDay": + return source.kind === "timeBlock" + ? { action: "setTimeBlock", start: target.date, durationMinutes: source.durationMinutes } + : { action: "setDeadlineAndClearTimeBlock", due: target.date }; + } +} diff --git a/src/lib/taskListsApi.ts b/src/lib/taskListsApi.ts new file mode 100644 index 0000000..0152e1e --- /dev/null +++ b/src/lib/taskListsApi.ts @@ -0,0 +1,102 @@ +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; +} + +// getTaskListById mirrors calendar.ts's own getCalendarById — the same +// find-by-id lookup every Task List colour resolution needs (TaskRow, +// TaskBlock, TaskDeadlineChip, MonthTaskChip), kept in one place rather than +// inlined at each call site. +export function getTaskListById( + taskLists: TaskList[], + id: number, +): TaskList | undefined { + return taskLists.find((list) => list.id === id); +} + +export const taskListsApi = { + // Every Task List the caller owns in the active Workspace. + async list(accessToken: string): Promise<TaskList[]> { + 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<TaskList> { + 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<TaskList> { + 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<TaskList> { + 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<TaskList> { + 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<void> { + 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<void>; + createTaskList: (name: string, color?: string) => Promise<TaskList>; + renameTaskList: (id: number, name: string) => Promise<void>; + recolorTaskList: (id: number, color: string) => Promise<void>; + setDefaultTaskList: (id: number) => Promise<void>; + deleteTaskList: (id: number) => Promise<void>; +} + +function requireAccessToken(): string { + const accessToken = useAuthStore.getState().accessToken; + if (!accessToken) throw new Error("Not authenticated."); + return accessToken; +} + +export const useTaskListsStore = create<TaskListsState>((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) }); + }, +})); diff --git a/src/lib/taskPriority.test.ts b/src/lib/taskPriority.test.ts new file mode 100644 index 0000000..e68764d --- /dev/null +++ b/src/lib/taskPriority.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + priorityLevelFromValue, + priorityRank, + priorityValueForLevel, +} from "./taskPriority"; + +describe("priorityLevelFromValue", () => { + it("maps 0 to none", () => { + expect(priorityLevelFromValue(0)).toBe("none"); + }); + + it("maps 1-4 to high", () => { + expect(priorityLevelFromValue(1)).toBe("high"); + expect(priorityLevelFromValue(4)).toBe("high"); + }); + + it("maps 5 to medium", () => { + expect(priorityLevelFromValue(5)).toBe("medium"); + }); + + it("maps 6-9 to low", () => { + expect(priorityLevelFromValue(6)).toBe("low"); + expect(priorityLevelFromValue(9)).toBe("low"); + }); +}); + +describe("priorityValueForLevel", () => { + it("round-trips through priorityLevelFromValue for every level", () => { + for (const level of ["none", "low", "medium", "high"] as const) { + expect(priorityLevelFromValue(priorityValueForLevel(level))).toBe(level); + } + }); +}); + +describe("priorityRank", () => { + it("ranks None lowest and High highest, the opposite of the raw value's own ordering", () => { + expect(priorityRank(0)).toBeLessThan(priorityRank(9)); // None < Low + expect(priorityRank(9)).toBeLessThan(priorityRank(5)); // Low < Medium + expect(priorityRank(5)).toBeLessThan(priorityRank(1)); // Medium < High + }); +}); diff --git a/src/lib/taskPriority.ts b/src/lib/taskPriority.ts new file mode 100644 index 0000000..1bda69f --- /dev/null +++ b/src/lib/taskPriority.ts @@ -0,0 +1,62 @@ +// A Task's Priority (#311, ADR-0083, CONTEXT.md): held as VTODO's raw +// `PRIORITY` value (0-9), never an app-specific enum, so a value written by +// a future native/CalDAV client would survive untranslated. None/Low/Medium/ +// High is a presentation mapping applied at the read (and write) edge, on +// the de facto convention most iCalendar clients (Apple, Thunderbird) share +// and RFC 5545 §3.8.1.9 groups by: 0 is undefined ("None"), 1-4 is the +// "highest" band, 5 is the middle value, 6-9 is the "lowest" band. +export type PriorityLevel = "none" | "low" | "medium" | "high"; + +export const PRIORITY_LEVELS: readonly PriorityLevel[] = ["none", "low", "medium", "high"]; + +export const PRIORITY_LEVEL_LABELS: Record<PriorityLevel, string> = { + none: "None", + low: "Low", + medium: "Medium", + high: "High", +}; + +/** The presentation level a raw 0-9 `PRIORITY` value maps to for display. */ +export function priorityLevelFromValue(value: number): PriorityLevel { + if (value === 0) return "none"; + if (value <= 4) return "high"; + if (value === 5) return "medium"; + return "low"; +} + +/** + * The raw value this app writes for `level`, picked from the middle of each + * band (1, 5, 9) so a later re-read maps back to the same level — never + * derived from whatever raw value a Task happened to already carry, since a + * User picking "Low" from a menu is choosing the level, not a number. + */ +export function priorityValueForLevel(level: PriorityLevel): number { + switch (level) { + case "none": + return 0; + case "high": + return 1; + case "medium": + return 5; + case "low": + return 9; + } +} + +/** + * This level's rank for ordering — **not** the raw value, which runs the + * other way (a lower number is a *higher* priority, and 0 is no priority at + * all rather than the lowest one). Ascending: None, Low, Medium, High. + */ +export function priorityRank(value: number): number { + switch (priorityLevelFromValue(value)) { + case "none": + return 0; + case "low": + return 1; + case "medium": + return 2; + case "high": + return 3; + } +} diff --git a/src/lib/taskScheduling.test.ts b/src/lib/taskScheduling.test.ts new file mode 100644 index 0000000..e545d0a --- /dev/null +++ b/src/lib/taskScheduling.test.ts @@ -0,0 +1,353 @@ +import { fromZonedTime } from "date-fns-tz"; +import { describe, expect, it, vi } from "vitest"; +import { + compareTasksWithinHeading, + bucketTasks, + taskBucket, + taskDeadlineFallsOnDay, + taskFallsOnMonthDay, + taskPlacement, + tasksByPriorityLevel, + tasksByTaskList, + type PlaceableTask, + type SchedulableTask, +} from "./taskScheduling"; + +// Pin the ambient zone to one that observes DST, and away from the zones +// explicit in the test bodies below — every assertion here passes a +// `viewerZone` explicitly, so a test that only passes with the ambient zone +// left at its default would be proof the function ignores its own +// parameter (mirroring expandOccurrences.test.ts's own posture). +vi.stubEnv("TZ", "Pacific/Auckland"); + +function makeTask(overrides: Partial<SchedulableTask> = {}): SchedulableTask { + return { + due: null, + start: null, + priority: 0, + createdAt: new Date(2026, 0, 1), + ...overrides, + }; +} + +describe("taskBucket", () => { + it("is noDate when neither Deadline nor Time block is present", () => { + const now = fromZonedTime(new Date(2026, 8, 15, 12, 0), "America/New_York"); + expect(taskBucket(makeTask(), now, "America/New_York")).toBe("noDate"); + }); + + it("is today when the Deadline falls on the same calendar day as now", () => { + const due = fromZonedTime(new Date(2026, 8, 15, 0, 0), "America/New_York"); + const now = fromZonedTime(new Date(2026, 8, 15, 18, 30), "America/New_York"); + expect(taskBucket(makeTask({ due }), now, "America/New_York")).toBe("today"); + }); + + it("is overdue when the Deadline is before today", () => { + const due = fromZonedTime(new Date(2026, 8, 14, 0, 0), "America/New_York"); + const now = fromZonedTime(new Date(2026, 8, 15, 9, 0), "America/New_York"); + expect(taskBucket(makeTask({ due }), now, "America/New_York")).toBe("overdue"); + }); + + it("is upcoming when the Deadline is after today", () => { + const due = fromZonedTime(new Date(2026, 8, 20, 0, 0), "America/New_York"); + const now = fromZonedTime(new Date(2026, 8, 15, 9, 0), "America/New_York"); + expect(taskBucket(makeTask({ due }), now, "America/New_York")).toBe("upcoming"); + }); + + it("falls back to the Time block's start when there is no Deadline", () => { + const start = fromZonedTime(new Date(2026, 8, 15, 14, 0), "America/New_York"); + const now = fromZonedTime(new Date(2026, 8, 15, 9, 0), "America/New_York"); + expect(taskBucket(makeTask({ start }), now, "America/New_York")).toBe("today"); + }); + + it("uses noDate only when neither axis is present — an overdue Time block with no Deadline still buckets from it", () => { + const start = fromZonedTime(new Date(2026, 8, 10, 14, 0), "America/New_York"); + const now = fromZonedTime(new Date(2026, 8, 15, 9, 0), "America/New_York"); + expect(taskBucket(makeTask({ start }), now, "America/New_York")).toBe("overdue"); + }); + + // ADR-0083 / #311 AC: a Deadline always beats a Time block, even though + // Time blocks themselves don't exist until a later ticket. + it("prefers a Friday Deadline over a Tuesday Time block: upcoming, not today", () => { + const now = fromZonedTime(new Date(2026, 8, 15, 9, 0), "America/New_York"); // Tuesday + const start = fromZonedTime(new Date(2026, 8, 15, 14, 0), "America/New_York"); // Tuesday, blocked + const due = fromZonedTime(new Date(2026, 8, 18, 0, 0), "America/New_York"); // Friday, due + expect(taskBucket(makeTask({ due, start }), now, "America/New_York")).toBe("upcoming"); + }); + + it("treats a Deadline just before midnight as overdue the instant the calendar day turns", () => { + const due = fromZonedTime(new Date(2026, 8, 14, 23, 59), "America/New_York"); + const stillSameDay = fromZonedTime(new Date(2026, 8, 14, 23, 59, 30), "America/New_York"); + const nextDay = fromZonedTime(new Date(2026, 8, 15, 0, 0, 1), "America/New_York"); + expect(taskBucket(makeTask({ due }), stillSameDay, "America/New_York")).toBe("today"); + expect(taskBucket(makeTask({ due }), nextDay, "America/New_York")).toBe("overdue"); + }); + + it("holds the calendar day across a DST transition (America/New_York springs forward 2026-03-08)", () => { + // 2026-03-08 02:30 America/New_York doesn't exist (clocks skip 02:00 -> + // 03:00); a Deadline set the day before must still read as due "today" + // for a `now` later that same DST-transition day, not shift a day either + // way because of the offset change mid-day. + const due = fromZonedTime(new Date(2026, 2, 8, 1, 0), "America/New_York"); + const now = fromZonedTime(new Date(2026, 2, 8, 10, 0), "America/New_York"); + expect(taskBucket(makeTask({ due }), now, "America/New_York")).toBe("today"); + }); + + it("derives 'today' from the current Viewer zone, which can read a different calendar day than the zone the Deadline was set in", () => { + // A single instant: 2026-09-15 23:30 America/New_York is already + // 2026-09-16 in Pacific/Auckland (many hours ahead). The Deadline is due + // 2026-09-15 wherever it's read from a fixed instant, so viewed from a + // Viewer zone far ahead, "today" has already turned into the 16th. + const due = fromZonedTime(new Date(2026, 8, 15, 0, 0), "America/New_York"); + const now = fromZonedTime(new Date(2026, 8, 15, 23, 30), "America/New_York"); + + expect(taskBucket(makeTask({ due }), now, "America/New_York")).toBe("today"); + expect(taskBucket(makeTask({ due }), now, "Pacific/Auckland")).toBe("overdue"); + }); +}); + +describe("compareTasksWithinHeading", () => { + it("orders an earlier Deadline before a later one", () => { + const earlier = makeTask({ due: new Date(2026, 8, 10) }); + const later = makeTask({ due: new Date(2026, 8, 20) }); + expect(compareTasksWithinHeading(earlier, later)).toBeLessThan(0); + expect(compareTasksWithinHeading(later, earlier)).toBeGreaterThan(0); + }); + + it("orders an undated Task after any dated one", () => { + const dated = makeTask({ due: new Date(2026, 8, 10) }); + const undated = makeTask({ due: null }); + expect(compareTasksWithinHeading(dated, undated)).toBeLessThan(0); + expect(compareTasksWithinHeading(undated, dated)).toBeGreaterThan(0); + }); + + it("breaks a Deadline tie by Priority descending (High before Medium before Low before None)", () => { + const due = new Date(2026, 8, 10); + const high = makeTask({ due, priority: 1 }); + const medium = makeTask({ due, priority: 5 }); + const low = makeTask({ due, priority: 9 }); + const none = makeTask({ due, priority: 0 }); + expect(compareTasksWithinHeading(high, medium)).toBeLessThan(0); + expect(compareTasksWithinHeading(medium, low)).toBeLessThan(0); + expect(compareTasksWithinHeading(low, none)).toBeLessThan(0); + }); + + it("breaks a Deadline-and-Priority tie by creation time ascending", () => { + const due = new Date(2026, 8, 10); + const createdFirst = makeTask({ due, priority: 5, createdAt: new Date(2026, 0, 1) }); + const createdSecond = makeTask({ due, priority: 5, createdAt: new Date(2026, 0, 2) }); + expect(compareTasksWithinHeading(createdFirst, createdSecond)).toBeLessThan(0); + }); + + it("is a true tie (0) when Deadline, Priority and creation time all match", () => { + const due = new Date(2026, 8, 10); + const createdAt = new Date(2026, 0, 1); + const a = makeTask({ due, priority: 5, createdAt }); + const b = makeTask({ due, priority: 5, createdAt }); + expect(compareTasksWithinHeading(a, b)).toBe(0); + }); +}); + +describe("taskPlacement", () => { + function placeable(overrides: Partial<PlaceableTask> = {}): PlaceableTask { + return { start: null, due: null, ...overrides }; + } + + it("is panel when neither a Time block nor a Deadline is present", () => { + expect(taskPlacement(placeable())).toBe("panel"); + }); + + it("is allDay when a Deadline is present and there is no Time block", () => { + expect(taskPlacement(placeable({ due: new Date(2026, 8, 15) }))).toBe("allDay"); + }); + + it("is grid when a Time block is present and there is no Deadline", () => { + expect(taskPlacement(placeable({ start: new Date(2026, 8, 15, 14, 0) }))).toBe("grid"); + }); + + // ADR-0083: a Time block wins outright — a Task due Friday but blocked + // Tuesday still renders on the grid, never doubled into the all-day lane. + it("is grid when both a Time block and a Deadline are present — the Time block wins", () => { + expect( + taskPlacement( + placeable({ start: new Date(2026, 8, 15, 14, 0), due: new Date(2026, 8, 18) }), + ), + ).toBe("grid"); + }); +}); + +describe("taskDeadlineFallsOnDay", () => { + it("is true when the Deadline falls on the same calendar day as day, in viewerZone", () => { + const due = fromZonedTime(new Date(2026, 8, 15, 22, 0), "America/New_York"); + const day = fromZonedTime(new Date(2026, 8, 15, 0, 0), "America/New_York"); + expect(taskDeadlineFallsOnDay(due, day, "America/New_York")).toBe(true); + }); + + it("is false when the Deadline falls on a different calendar day than day", () => { + const due = fromZonedTime(new Date(2026, 8, 15, 22, 0), "America/New_York"); + const day = fromZonedTime(new Date(2026, 8, 16, 0, 0), "America/New_York"); + expect(taskDeadlineFallsOnDay(due, day, "America/New_York")).toBe(false); + }); + + it("re-derives which day the Deadline falls on from viewerZone, not the zone it was set in", () => { + // Fixed instants, mirroring taskBucket's own cross-zone test: only the + // viewerZone argument varies below. 2026-09-15 23:30 America/New_York is + // already 2026-09-16 in Pacific/Auckland, so the same due/day pair reads + // as the same calendar day from New York and as different days from + // Auckland. + const due = fromZonedTime(new Date(2026, 8, 15, 0, 0), "America/New_York"); + const day = fromZonedTime(new Date(2026, 8, 15, 23, 30), "America/New_York"); + + expect(taskDeadlineFallsOnDay(due, day, "America/New_York")).toBe(true); + expect(taskDeadlineFallsOnDay(due, day, "Pacific/Auckland")).toBe(false); + }); +}); + +describe("taskFallsOnMonthDay", () => { + function placeable(overrides: Partial<PlaceableTask> = {}): PlaceableTask { + return { start: null, due: null, ...overrides }; + } + + it("is false for a panel-only Task with neither a Time block nor a Deadline", () => { + const day = fromZonedTime(new Date(2026, 8, 15, 0, 0), "America/New_York"); + expect(taskFallsOnMonthDay(placeable(), day, "America/New_York")).toBe(false); + }); + + it("is true when the Time block's start falls on day, in viewerZone", () => { + const start = fromZonedTime(new Date(2026, 8, 15, 14, 0), "America/New_York"); + const day = fromZonedTime(new Date(2026, 8, 15, 0, 0), "America/New_York"); + expect(taskFallsOnMonthDay(placeable({ start }), day, "America/New_York")).toBe(true); + }); + + it("is true when the Deadline falls on day and there is no Time block", () => { + const due = fromZonedTime(new Date(2026, 8, 15, 0, 0), "America/New_York"); + const day = fromZonedTime(new Date(2026, 8, 15, 12, 0), "America/New_York"); + expect(taskFallsOnMonthDay(placeable({ due }), day, "America/New_York")).toBe(true); + }); + + // ADR-0083: the Time block wins outright — a Task blocked Tuesday but due + // Friday renders on Tuesday's Month cell, not Friday's. + it("prefers the Time block's day over the Deadline's when both are present", () => { + const start = fromZonedTime(new Date(2026, 8, 15, 14, 0), "America/New_York"); + const due = fromZonedTime(new Date(2026, 8, 18, 0, 0), "America/New_York"); + const tuesday = fromZonedTime(new Date(2026, 8, 15, 0, 0), "America/New_York"); + const friday = fromZonedTime(new Date(2026, 8, 18, 0, 0), "America/New_York"); + + expect(taskFallsOnMonthDay(placeable({ start, due }), tuesday, "America/New_York")).toBe(true); + expect(taskFallsOnMonthDay(placeable({ start, due }), friday, "America/New_York")).toBe(false); + }); + + it("is false when the effective date falls on a different calendar day than day", () => { + const start = fromZonedTime(new Date(2026, 8, 15, 14, 0), "America/New_York"); + const day = fromZonedTime(new Date(2026, 8, 16, 0, 0), "America/New_York"); + expect(taskFallsOnMonthDay(placeable({ start }), day, "America/New_York")).toBe(false); + }); +}); + +describe("bucketTasks", () => { + it("splits Tasks into their four buckets, each pre-sorted", () => { + const now = fromZonedTime(new Date(2026, 8, 15, 9, 0), "America/New_York"); + const overdueTask = makeTask({ due: fromZonedTime(new Date(2026, 8, 10), "America/New_York") }); + const todaySoon = makeTask({ + due: fromZonedTime(new Date(2026, 8, 15), "America/New_York"), + priority: 9, + createdAt: new Date(2026, 0, 1), + }); + const todayUrgent = makeTask({ + due: fromZonedTime(new Date(2026, 8, 15), "America/New_York"), + priority: 1, + createdAt: new Date(2026, 0, 2), + }); + const upcomingTask = makeTask({ due: fromZonedTime(new Date(2026, 8, 20), "America/New_York") }); + const noDateTask = makeTask(); + + const buckets = bucketTasks( + [overdueTask, todaySoon, upcomingTask, noDateTask, todayUrgent], + now, + "America/New_York", + ); + + expect(buckets.overdue).toEqual([overdueTask]); + expect(buckets.today).toEqual([todayUrgent, todaySoon]); + expect(buckets.upcoming).toEqual([upcomingTask]); + expect(buckets.noDate).toEqual([noDateTask]); + }); +}); + +describe("tasksByPriorityLevel", () => { + it("splits Tasks into their four Priority headings, each pre-sorted", () => { + const due = new Date(2026, 8, 10); + const high = makeTask({ due, priority: 1 }); + const anotherHigh = makeTask({ due: new Date(2026, 8, 5), priority: 2 }); + const medium = makeTask({ due, priority: 5 }); + const low = makeTask({ due, priority: 9 }); + const none = makeTask({ due, priority: 0 }); + + const headings = tasksByPriorityLevel([medium, none, anotherHigh, low, high]); + + expect(headings.high).toEqual([anotherHigh, high]); + expect(headings.medium).toEqual([medium]); + expect(headings.low).toEqual([low]); + expect(headings.none).toEqual([none]); + }); + + it("orders within one Priority level by the same Deadline-then-creation-time rule a Task bucket uses", () => { + const earlier = makeTask({ priority: 5, due: new Date(2026, 8, 10) }); + const later = makeTask({ priority: 5, due: new Date(2026, 8, 20) }); + + const headings = tasksByPriorityLevel([later, earlier]); + + expect(headings.medium).toEqual([earlier, later]); + }); + + it("breaks a Deadline tie within one Priority level by creation time ascending", () => { + const due = new Date(2026, 8, 10); + const createdFirst = makeTask({ priority: 5, due, createdAt: new Date(2026, 0, 1) }); + const createdSecond = makeTask({ priority: 5, due, createdAt: new Date(2026, 0, 2) }); + + const headings = tasksByPriorityLevel([createdSecond, createdFirst]); + + expect(headings.medium).toEqual([createdFirst, createdSecond]); + }); + + it("holds an empty array for a Priority level with no Tasks", () => { + const headings = tasksByPriorityLevel([makeTask({ priority: 1 })]); + + expect(headings.low).toEqual([]); + expect(headings.none).toEqual([]); + }); +}); + +describe("tasksByTaskList", () => { + function makeListTask(taskListId: number, overrides: Partial<SchedulableTask> = {}) { + return { ...makeTask(overrides), taskListId }; + } + + it("splits Tasks by their own Task List id, each pre-sorted", () => { + const workLater = makeListTask(1, { due: new Date(2026, 8, 20) }); + const workEarlier = makeListTask(1, { due: new Date(2026, 8, 10) }); + const homeTask = makeListTask(2, { due: new Date(2026, 8, 15) }); + + const headings = tasksByTaskList([workLater, homeTask, workEarlier]); + + expect(headings.get(1)).toEqual([workEarlier, workLater]); + expect(headings.get(2)).toEqual([homeTask]); + }); + + it("breaks a Deadline tie within one Task List by Priority descending, then by creation time ascending", () => { + const due = new Date(2026, 8, 10); + const high = makeListTask(1, { due, priority: 1 }); + const mediumCreatedFirst = makeListTask(1, { due, priority: 5, createdAt: new Date(2026, 0, 1) }); + const mediumCreatedSecond = makeListTask(1, { due, priority: 5, createdAt: new Date(2026, 0, 2) }); + + const headings = tasksByTaskList([mediumCreatedSecond, high, mediumCreatedFirst]); + + expect(headings.get(1)).toEqual([high, mediumCreatedFirst, mediumCreatedSecond]); + }); + + it("holds no entry for a Task List with no Tasks", () => { + const headings = tasksByTaskList([makeListTask(1)]); + + expect(headings.has(2)).toBe(false); + }); +}); diff --git a/src/lib/taskScheduling.ts b/src/lib/taskScheduling.ts new file mode 100644 index 0000000..089039f --- /dev/null +++ b/src/lib/taskScheduling.ts @@ -0,0 +1,256 @@ +import { differenceInCalendarDays } from "date-fns"; +import { toZonedTime } from "date-fns-tz"; +import { priorityLevelFromValue, priorityRank, type PriorityLevel } from "./taskPriority"; + +/** + * The four headings the Tasks panel sorts Tasks under (#311, ADR-0083, + * CONTEXT.md's Task bucket entry), derived at render and never stored — + * nothing moves a Task between buckets at midnight because there is nothing + * stored to move. + */ +export type TaskBucket = "overdue" | "today" | "upcoming" | "noDate"; + +export const TASK_BUCKET_ORDER: readonly TaskBucket[] = [ + "overdue", + "today", + "upcoming", + "noDate", +]; + +export const TASK_BUCKET_LABELS: Record<TaskBucket, string> = { + overdue: "Overdue", + today: "Today", + upcoming: "Upcoming", + noDate: "No date", +}; + +/** + * The fields `taskBucket`/`compareTasksWithinBucket` read off a Task — + * structural rather than the full `Task` shape, so a caller can pass either + * the real thing or a bare test fixture. + */ +export interface SchedulableTask { + /** The Deadline (`DUE`) — decides the bucket outright when present. */ + due: Date | null; + /** The Time block's start (`DTSTART`) — the bucket's fallback when there + * is no Deadline. Nothing sets this yet (#312/#313 land the Time block + * itself); this is the groundwork ADR-0083 calls for. */ + start: Date | null; + priority: number; + createdAt: Date; +} + +/** + * `instant` converted to a Date whose plain (system-local) getters read the + * wall-clock calendar day it falls on in `zone` — the same trick + * `floatingTime.ts`'s `toFloating` relies on, here used just for the day, + * not a full floating-frame RRULE run. + */ +function zonedCalendarDay(instant: Date, zone: string): Date { + return toZonedTime(instant, zone); +} + +/** + * The three surfaces placement precedence (#312, ADR-0083) can resolve a + * Task to. There is no discriminator field on a Task itself — presence of + * `start`/`due` IS the state `taskPlacement` reads. + */ +export type TaskPlacement = "grid" | "allDay" | "panel"; + +/** The fields `taskPlacement` reads — structural, like `SchedulableTask`. */ +export interface PlaceableTask { + /** The Time block's start (`DTSTART`) — wins outright when present. */ + start: Date | null; + /** The Deadline (`DUE`) — the fallback surface when there is no Time block. */ + due: Date | null; +} + +/** + * Placement precedence (#312, ADR-0083): a Task renders in exactly one + * place. A Time block wins outright and puts it on the hourly grid; failing + * that, a Deadline puts it in the all-day lane; failing that, it is + * panel-only. Every later grid ticket (Time-blocking, drag) depends on this + * holding for all four field combinations. + */ +export function taskPlacement(task: PlaceableTask): TaskPlacement { + if (task.start) return "grid"; + if (task.due) return "allDay"; + return "panel"; +} + +/** + * Whether `due` — a Task's Deadline — falls on the same calendar date as + * `day`, both read through `viewerZone`. The all-day lane's own per-day + * filter for a Deadline-only Task (#312), using the same zone handling + * `taskBucket` does rather than comparing raw UTC fields, so a Deadline set + * near midnight lands on the date it reads as in the Viewer zone, not + * whatever date its UTC instant happens to carry. + */ +export function taskDeadlineFallsOnDay(due: Date, day: Date, viewerZone: string): boolean { + return instantFallsOnDay(due, day, viewerZone); +} + +/** + * Whether `instant` falls on the same calendar date as `day`, in + * `viewerZone` — the general form `taskDeadlineFallsOnDay` specializes to a + * Deadline specifically. Used directly by `taskFallsOnMonthDay` (#315), + * which needs the same day comparison against whichever of a Task's two + * axes placement precedence picked, not a Deadline by name. + */ +function instantFallsOnDay(instant: Date, day: Date, viewerZone: string): boolean { + return ( + differenceInCalendarDays( + zonedCalendarDay(instant, viewerZone), + zonedCalendarDay(day, viewerZone), + ) === 0 + ); +} + +/** + * Whether `task` should render on `day` in Month view (#315, ADR-0083): + * placement precedence's Time-block-first, Deadline-second rule, collapsed + * to a single day test since Month draws both surfaces as one chip rather + * than splitting them across the hourly grid and the all-day lane the way + * Day/Week do. `false` for a panel-only Task (neither axis set). + */ +export function taskFallsOnMonthDay(task: PlaceableTask, day: Date, viewerZone: string): boolean { + const effective = task.start ?? task.due; + if (!effective) return false; + return instantFallsOnDay(effective, day, viewerZone); +} + +/** + * The Task bucket `task` falls into, derived from its Deadline against + * today in `viewerZone` — falling back to the Time block's start when there + * is no Deadline, and `noDate` when neither is present (ADR-0083). A + * Deadline always wins over a Time block: a Friday-due Task blocked on + * Tuesday is `upcoming`, never `today`, because the fallback is only + * consulted when `due` is absent outright. + */ +export function taskBucket(task: SchedulableTask, now: Date, viewerZone: string): TaskBucket { + const effective = task.due ?? task.start; + if (!effective) return "noDate"; + + const dayDiff = differenceInCalendarDays( + zonedCalendarDay(effective, viewerZone), + zonedCalendarDay(now, viewerZone), + ); + if (dayDiff < 0) return "overdue"; + if (dayDiff === 0) return "today"; + return "upcoming"; +} + +/** + * Within-heading ordering (#311, #316, issue #309 story 24): Deadline + * ascending with undated Tasks last, then Priority descending (by level, not + * the raw value's own inverted scale — see `priorityRank`), then creation + * time ascending, so the top of a heading is reliably the thing to do next — + * on every Group by axis a Task bucket, a Task List, or a Priority level + * alike, since none of them carries an ordering of its own. + */ +export function compareTasksWithinHeading(a: SchedulableTask, b: SchedulableTask): number { + const dueDiff = compareDeadlineAscendingUndatedLast(a.due, b.due); + if (dueDiff !== 0) return dueDiff; + + const priorityDiff = priorityRank(b.priority) - priorityRank(a.priority); + if (priorityDiff !== 0) return priorityDiff; + + return a.createdAt.getTime() - b.createdAt.getTime(); +} + +function compareDeadlineAscendingUndatedLast(a: Date | null, b: Date | null): number { + if (a === null && b === null) return 0; + if (a === null) return 1; + if (b === null) return -1; + return a.getTime() - b.getTime(); +} + +/** + * `tasks` split into the four Task buckets, each already sorted by + * `compareTasksWithinHeading` — what the Tasks panel renders directly. + * Generic over `T` so a caller can hand it real `Task`s (which carry an id, + * title, etc. beyond `SchedulableTask`'s fields) and get them back typed. + */ +export function bucketTasks<T extends SchedulableTask>( + tasks: T[], + now: Date, + viewerZone: string, +): Record<TaskBucket, T[]> { + const buckets: Record<TaskBucket, T[]> = { + overdue: [], + today: [], + upcoming: [], + noDate: [], + }; + + for (const task of tasks) { + buckets[taskBucket(task, now, viewerZone)].push(task); + } + + for (const bucket of TASK_BUCKET_ORDER) { + buckets[bucket].sort(compareTasksWithinHeading); + } + + return buckets; +} + +/** + * Priority levels High to None — the order the panel's Group by: Priority + * axis renders its headings in (#316, ADR-0083). The reverse of + * taskPriority.ts's own `PRIORITY_LEVELS` (ascending, for the detail + * surface's picker), which orders least to most rather than most pressing + * first. + */ +export const PRIORITY_HEADING_ORDER: readonly PriorityLevel[] = ["high", "medium", "low", "none"]; + +/** + * `tasks` split by Priority level, each already sorted by + * `compareTasksWithinHeading` — the Group by: Priority axis's data source + * (#316, ADR-0083). Within-heading ordering is unchanged from a Task + * bucket's: Priority already breaks a Deadline tie there, so grouping by it + * outright doesn't disturb Deadline-then-creation-time ordering within one + * level. + */ +export function tasksByPriorityLevel<T extends SchedulableTask>( + tasks: T[], +): Record<PriorityLevel, T[]> { + const headings: Record<PriorityLevel, T[]> = { none: [], low: [], medium: [], high: [] }; + + for (const task of tasks) { + headings[priorityLevelFromValue(task.priority)].push(task); + } + + for (const level of PRIORITY_HEADING_ORDER) { + headings[level].sort(compareTasksWithinHeading); + } + + return headings; +} + +/** + * `tasks` split by their own Task List id, each already sorted by + * `compareTasksWithinHeading` — the Group by: Task List axis's data source + * (#316, ADR-0083). Keyed rather than pre-ordered: heading order and colour + * come from the Task Lists the panel already holds (the checked ones, in + * list order), so this only has to answer which Tasks belong under each id. + */ +export function tasksByTaskList<T extends SchedulableTask & { taskListId: number }>( + tasks: T[], +): Map<number, T[]> { + const headings = new Map<number, T[]>(); + + for (const task of tasks) { + const forList = headings.get(task.taskListId); + if (forList) { + forList.push(task); + } else { + headings.set(task.taskListId, [task]); + } + } + + for (const forList of headings.values()) { + forList.sort(compareTasksWithinHeading); + } + + return headings; +} diff --git a/src/lib/taskTimeBlockSegments.test.ts b/src/lib/taskTimeBlockSegments.test.ts new file mode 100644 index 0000000..baecfdf --- /dev/null +++ b/src/lib/taskTimeBlockSegments.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { getTaskTimeBlockDaySegments, taskTimeBlockEnd } from "./taskTimeBlockSegments"; +import type { Task } from "./tasksApi"; + +function makeTask(overrides: Partial<Task> = {}): Task { + return { + id: 1, + taskListId: 1, + title: "Write the spec", + notes: "", + due: null, + start: null, + durationMinutes: null, + priority: 0, + completed: false, + createdAt: new Date(2026, 0, 1), + ...overrides, + }; +} + +describe("taskTimeBlockEnd", () => { + it("is start plus durationMinutes", () => { + const task = makeTask({ start: new Date(2026, 8, 10, 9, 0), durationMinutes: 90 }); + expect(taskTimeBlockEnd(task)).toEqual(new Date(2026, 8, 10, 10, 30)); + }); +}); + +describe("getTaskTimeBlockDaySegments", () => { + it("returns one segment for a Task blocked wholly inside the day", () => { + const task = makeTask({ start: new Date(2026, 8, 10, 9, 0), durationMinutes: 60 }); + const segments = getTaskTimeBlockDaySegments([task], new Date(2026, 8, 10)); + expect(segments).toEqual([ + { task, start: new Date(2026, 8, 10, 9, 0), end: new Date(2026, 8, 10, 10, 0) }, + ]); + }); + + it("excludes a Task blocked on a different day", () => { + const task = makeTask({ start: new Date(2026, 8, 11, 9, 0), durationMinutes: 60 }); + expect(getTaskTimeBlockDaySegments([task], new Date(2026, 8, 10))).toEqual([]); + }); + + it("excludes a Task with no Time block", () => { + const task = makeTask({ due: new Date(2026, 8, 10) }); + expect(getTaskTimeBlockDaySegments([task], new Date(2026, 8, 10))).toEqual([]); + }); + + it("clips a block crossing midnight to each day it touches", () => { + const task = makeTask({ start: new Date(2026, 8, 10, 23, 0), durationMinutes: 120 }); + + const firstDay = getTaskTimeBlockDaySegments([task], new Date(2026, 8, 10)); + expect(firstDay).toHaveLength(1); + expect(firstDay[0].start).toEqual(new Date(2026, 8, 10, 23, 0)); + expect(firstDay[0].end).toEqual(new Date(2026, 8, 11, 0, 0)); + + const secondDay = getTaskTimeBlockDaySegments([task], new Date(2026, 8, 11)); + expect(secondDay).toHaveLength(1); + expect(secondDay[0].start).toEqual(new Date(2026, 8, 11, 0, 0)); + expect(secondDay[0].end).toEqual(new Date(2026, 8, 11, 1, 0)); + }); +}); diff --git a/src/lib/taskTimeBlockSegments.ts b/src/lib/taskTimeBlockSegments.ts new file mode 100644 index 0000000..329bbbd --- /dev/null +++ b/src/lib/taskTimeBlockSegments.ts @@ -0,0 +1,54 @@ +import { addDays, addMinutes, startOfDay } from "date-fns"; +import type { Task } from "./tasksApi"; + +/** + * `getDaySegments`'s (`occurrenceSegments.ts`) counterpart for a Task's Time + * block (#313, ADR-0083): the block's own `[start, end)` — `start` plus + * `durationMinutes` — clipped to one day's `[00:00, 24:00)` bounds, so the + * hourly grid never has to render a block past its own midnight. + */ +export interface TaskTimeBlockSegment { + task: Task; + start: Date; + end: Date; +} + +/** A Time-blocked Task's own end instant: `start` plus `durationMinutes` + * (ADR-0083's VTODO `DTSTART`+`DURATION`, never `DUE`). Only meaningful for + * a Task that actually has a Time block — callers filter for that first. */ +export function taskTimeBlockEnd(task: Task): Date { + return addMinutes(task.start as Date, task.durationMinutes ?? 0); +} + +function taskTimeBlockIntersectsDay(task: Task, day: Date): boolean { + if (!task.start || !task.durationMinutes) return false; + const dayStart = startOfDay(day); + const dayEnd = addDays(dayStart, 1); + return task.start < dayEnd && taskTimeBlockEnd(task) > dayStart; +} + +function taskTimeBlockSegmentForDay(task: Task, day: Date): TaskTimeBlockSegment { + const dayStart = startOfDay(day); + const dayEnd = addDays(dayStart, 1); + const start = task.start as Date; + const end = taskTimeBlockEnd(task); + return { + task, + start: start < dayStart ? dayStart : start, + end: end > dayEnd ? dayEnd : end, + }; +} + +/** + * Every `tasks` Time block that touches `day`, clipped to its bounds — one + * per Task placed on the hourly grid (`taskPlacement(task) === "grid"`) that + * intersects it. A Task with no Time block never produces a segment: the + * caller is expected to have already applied placement precedence and "Show + * tasks on calendar"/"Show completed", the same division TimeGrid already + * makes for `deadlineTasks`. + */ +export function getTaskTimeBlockDaySegments(tasks: Task[], day: Date): TaskTimeBlockSegment[] { + return tasks + .filter((task) => taskTimeBlockIntersectsDay(task, day)) + .map((task) => taskTimeBlockSegmentForDay(task, day)); +} diff --git a/src/lib/tasksApi.ts b/src/lib/tasksApi.ts new file mode 100644 index 0000000..410ed3e --- /dev/null +++ b/src/lib/tasksApi.ts @@ -0,0 +1,227 @@ +import { authedFetch, errorFromResponse } from "./apiClient"; +import { workspaceHeaders } from "./workspaceHeaders"; + +// Tasks (#310, #311, 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. Due/start/createdAt are real UTC instants on the wire (never +// date-only strings the way an all-day Event's are, ADR-0017) — a Task's +// Deadline and Time block carry no `tzid` of their own (ADR-0083), so they +// are "floating" in exactly the sense a tzid-less Event is: reinterpreted +// through whichever Viewer zone reads them, per taskScheduling.ts. + +interface TaskWire { + id: number; + taskListId: number; + title: string; + notes: string; + due: string | null; + start: string | null; + durationMinutes: number | null; + priority: number; + completed: boolean; + createdAt: string; +} + +export interface Task { + id: number; + taskListId: number; + title: string; + notes: string; + due: Date | null; + start: Date | null; + durationMinutes: number | null; + priority: number; + completed: boolean; + createdAt: Date; +} + +function fromWire(wire: TaskWire): Task { + return { + id: wire.id, + taskListId: wire.taskListId, + title: wire.title, + notes: wire.notes, + due: wire.due ? new Date(wire.due) : null, + start: wire.start ? new Date(wire.start) : null, + durationMinutes: wire.durationMinutes, + priority: wire.priority, + completed: wire.completed, + createdAt: new Date(wire.createdAt), + }; +} + +export const tasksApi = { + // Every incomplete Task the caller owns in the active Workspace, + // unwindowed by date. + async list(accessToken: string): Promise<Task[]> { + const response = await authedFetch(accessToken, "/api/tasks/", { + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + + return ((await response.json()) as TaskWire[]).map(fromWire); + }, + + // 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<Task[]> { + const response = await authedFetch(accessToken, "/api/tasks/completed", { + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + + return ((await response.json()) as TaskWire[]).map(fromWire); + }, + + // Creates a Task titled title in taskListId — the quick-add field's + // commit-on-Enter. + async create(accessToken: string, title: string, taskListId: number): Promise<Task> { + 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 fromWire((await response.json()) as TaskWire); + }, + + // Changes id's notes — the detail surface's Notes field. + async updateNotes(accessToken: string, id: number, notes: string): Promise<Task> { + const response = await authedFetch(accessToken, `/api/tasks/${id}/notes`, { + method: "PATCH", + credentials: "include", + headers: workspaceHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ notes }), + }); + if (!response.ok) throw await errorFromResponse(response); + + return fromWire((await response.json()) as TaskWire); + }, + + // Sets id's Deadline — the detail surface's Deadline field. Never touches + // the Time block, the two axes being independent (ADR-0083). + async setDeadline(accessToken: string, id: number, due: Date): Promise<Task> { + const response = await authedFetch(accessToken, `/api/tasks/${id}/deadline`, { + method: "PUT", + credentials: "include", + headers: workspaceHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ due: due.toISOString() }), + }); + if (!response.ok) throw await errorFromResponse(response); + + return fromWire((await response.json()) as TaskWire); + }, + + // Clears id's Deadline. + async clearDeadline(accessToken: string, id: number): Promise<Task> { + const response = await authedFetch(accessToken, `/api/tasks/${id}/deadline`, { + method: "DELETE", + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + + return fromWire((await response.json()) as TaskWire); + }, + + // Sets id's Time block (start + a duration) — a panel row dropped on the + // hourly grid (#313, ADR-0083). Never touches the Deadline, the two axes + // being independent. + async setTimeBlock( + accessToken: string, + id: number, + start: Date, + durationMinutes: number, + ): Promise<Task> { + const response = await authedFetch(accessToken, `/api/tasks/${id}/time-block`, { + method: "PUT", + credentials: "include", + headers: workspaceHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ start: start.toISOString(), durationMinutes }), + }); + if (!response.ok) throw await errorFromResponse(response); + + return fromWire((await response.json()) as TaskWire); + }, + + // Clears id's Time block (start and duration both to null) — dragging a + // block back to the panel, an all-day-lane drop's second write, or the + // Task's own "Unschedule" menu item (#314). Never touches the Deadline. + async clearTimeBlock(accessToken: string, id: number): Promise<Task> { + const response = await authedFetch(accessToken, `/api/tasks/${id}/time-block`, { + method: "DELETE", + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + + return fromWire((await response.json()) as TaskWire); + }, + + // Changes id's raw PRIORITY value (0-9) — the detail surface's Priority + // field, chosen from None/Low/Medium/High and translated by the caller + // (taskPriority.ts), never sent as a level of its own. + async updatePriority(accessToken: string, id: number, priority: number): Promise<Task> { + const response = await authedFetch(accessToken, `/api/tasks/${id}/priority`, { + method: "PATCH", + credentials: "include", + headers: workspaceHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ priority }), + }); + if (!response.ok) throw await errorFromResponse(response); + + return fromWire((await response.json()) as TaskWire); + }, + + // Moves id into taskListId — the detail surface's Task List field. + async move(accessToken: string, id: number, taskListId: number): Promise<Task> { + const response = await authedFetch(accessToken, `/api/tasks/${id}/task-list`, { + method: "PUT", + credentials: "include", + headers: workspaceHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ taskListId }), + }); + if (!response.ok) throw await errorFromResponse(response); + + return fromWire((await response.json()) as TaskWire); + }, + + // Marks id completed. + async complete(accessToken: string, id: number): Promise<Task> { + const response = await authedFetch(accessToken, `/api/tasks/${id}/complete`, { + method: "PUT", + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + + return fromWire((await response.json()) as TaskWire); + }, + + // Un-marks id completed. + async uncomplete(accessToken: string, id: number): Promise<Task> { + const response = await authedFetch(accessToken, `/api/tasks/${id}/complete`, { + method: "DELETE", + credentials: "include", + headers: workspaceHeaders(), + }); + if (!response.ok) throw await errorFromResponse(response); + + return fromWire((await response.json()) as TaskWire); + }, + + // Deletes id outright — the detail surface's delete action. + async remove(accessToken: string, id: number): Promise<void> { + 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..ae8e43c --- /dev/null +++ b/src/lib/tasksStore.test.ts @@ -0,0 +1,400 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./tasksApi", () => ({ + tasksApi: { + list: vi.fn(), + listCompleted: vi.fn(), + create: vi.fn(), + updateNotes: vi.fn(), + setDeadline: vi.fn(), + clearDeadline: vi.fn(), + setTimeBlock: vi.fn(), + clearTimeBlock: vi.fn(), + updatePriority: vi.fn(), + move: 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", + notes: "", + due: null, + start: null, + durationMinutes: null, + priority: 0, + completed: false, + createdAt: new Date(2026, 0, 1), +}; + +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("detail surface writes", () => { + it("updateTaskNotes replaces a completed task in place, in completedTasks", async () => { + const completed = { ...buyMilk, completed: true }; + useTasksStore.setState({ tasks: [], completedTasks: [completed] }); + const withNotes = { ...completed, notes: "2%, not whole" }; + vi.mocked(tasksApi.updateNotes).mockResolvedValue(withNotes); + + await useTasksStore.getState().updateTaskNotes(buyMilk.id, "2%, not whole"); + + expect(useTasksStore.getState().completedTasks).toEqual([withNotes]); + expect(useTasksStore.getState().tasks).toEqual([]); + }); + + it("setTaskDeadline and clearTaskDeadline replace the task with the server's response", async () => { + useTasksStore.setState({ tasks: [buyMilk], completedTasks: [] }); + const due = new Date(2026, 8, 15); + const withDeadline = { ...buyMilk, due }; + vi.mocked(tasksApi.setDeadline).mockResolvedValue(withDeadline); + + await useTasksStore.getState().setTaskDeadline(buyMilk.id, due); + expect(useTasksStore.getState().tasks).toEqual([withDeadline]); + + vi.mocked(tasksApi.clearDeadline).mockResolvedValue(buyMilk); + await useTasksStore.getState().clearTaskDeadline(buyMilk.id); + expect(useTasksStore.getState().tasks).toEqual([buyMilk]); + }); + + it("updateTaskPriority replaces the task with the server's raw value", async () => { + useTasksStore.setState({ tasks: [buyMilk], completedTasks: [] }); + const prioritized = { ...buyMilk, priority: 1 }; + vi.mocked(tasksApi.updatePriority).mockResolvedValue(prioritized); + + await useTasksStore.getState().updateTaskPriority(buyMilk.id, 1); + + expect(useTasksStore.getState().tasks).toEqual([prioritized]); + }); + + it("moveTask replaces the task's taskListId", async () => { + useTasksStore.setState({ tasks: [buyMilk], completedTasks: [] }); + const moved = { ...buyMilk, taskListId: work.id }; + vi.mocked(tasksApi.move).mockResolvedValue(moved); + + await useTasksStore.getState().moveTask(buyMilk.id, work.id); + + expect(useTasksStore.getState().tasks).toEqual([moved]); + }); + + it("deleteTask removes the task from whichever list holds it", async () => { + useTasksStore.setState({ tasks: [buyMilk], completedTasks: [] }); + vi.mocked(tasksApi.remove).mockResolvedValue(undefined); + + await useTasksStore.getState().deleteTask(buyMilk.id); + + expect(useTasksStore.getState().tasks).toEqual([]); + }); +}); + +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(); + }); +}); + +// setTaskTimeBlock covers #313: the grid drop's own write, optimistic like +// setTaskCompleted (ADR-0067) since the block appearing on the grid is the +// feedback — and, in every case, leaving the Deadline exactly as it was, the +// two axes being independent (ADR-0083). +describe("setTaskTimeBlock", () => { + const dueDated = { ...buyMilk, due: new Date(2026, 8, 20) }; + + it("paints the Time block immediately, before the API call resolves, leaving the Deadline untouched", () => { + useTasksStore.setState({ tasks: [dueDated], completedTasks: [] }); + const start = new Date(2026, 8, 15, 14, 0); + let resolveSet: () => void = () => {}; + vi.mocked(tasksApi.setTimeBlock).mockReturnValue( + new Promise((resolve) => { + resolveSet = () => resolve({ ...dueDated, start, durationMinutes: 60 }); + }), + ); + + const promise = useTasksStore.getState().setTaskTimeBlock(dueDated.id, start, 60); + + expect(useTasksStore.getState().tasks).toEqual([{ ...dueDated, start, durationMinutes: 60 }]); + expect(tasksApi.setTimeBlock).toHaveBeenCalledWith("token-123", dueDated.id, start, 60); + + resolveSet(); + return promise; + }); + + it("rolls back and shows a toast when the server refuses, leaving the Deadline untouched", async () => { + useTasksStore.setState({ tasks: [dueDated], completedTasks: [] }); + const start = new Date(2026, 8, 15, 14, 0); + vi.mocked(tasksApi.setTimeBlock).mockRejectedValue(new Error("network error")); + + const ok = await useTasksStore.getState().setTaskTimeBlock(dueDated.id, start, 60); + + expect(ok).toBe(false); + expect(useTasksStore.getState().tasks).toEqual([dueDated]); + expect(toast.error).toHaveBeenCalledWith(`Couldn't schedule "${dueDated.title}".`); + }); + + it("resolves false and touches nothing when the task isn't in either list", async () => { + useTasksStore.setState({ tasks: [], completedTasks: [] }); + + const ok = await useTasksStore.getState().setTaskTimeBlock(999, new Date(), 60); + + expect(ok).toBe(false); + expect(tasksApi.setTimeBlock).not.toHaveBeenCalled(); + }); +}); + +// clearTaskTimeBlock covers #314's "back to panel" drop-table row and the +// Task's "Unschedule" menu item — both just want the block gone, optimistic +// like setTaskTimeBlock since the block vanishing from the grid is the +// feedback, and in every case leaving the Deadline untouched. +describe("clearTaskTimeBlock", () => { + const blocked = { + ...buyMilk, + due: new Date(2026, 8, 20), + start: new Date(2026, 8, 15, 14, 0), + durationMinutes: 60, + }; + + it("clears the Time block immediately, before the API call resolves, leaving the Deadline untouched", () => { + useTasksStore.setState({ tasks: [blocked], completedTasks: [] }); + let resolveClear: () => void = () => {}; + vi.mocked(tasksApi.clearTimeBlock).mockReturnValue( + new Promise((resolve) => { + resolveClear = () => resolve({ ...blocked, start: null, durationMinutes: null }); + }), + ); + + const promise = useTasksStore.getState().clearTaskTimeBlock(blocked.id); + + expect(useTasksStore.getState().tasks).toEqual([{ ...blocked, start: null, durationMinutes: null }]); + expect(tasksApi.clearTimeBlock).toHaveBeenCalledWith("token-123", blocked.id); + + resolveClear(); + return promise; + }); + + it("rolls back and shows a toast when the server refuses", async () => { + useTasksStore.setState({ tasks: [blocked], completedTasks: [] }); + vi.mocked(tasksApi.clearTimeBlock).mockRejectedValue(new Error("network error")); + + const ok = await useTasksStore.getState().clearTaskTimeBlock(blocked.id); + + expect(ok).toBe(false); + expect(useTasksStore.getState().tasks).toEqual([blocked]); + expect(toast.error).toHaveBeenCalledWith(`Couldn't unschedule "${blocked.title}".`); + }); + + it("resolves false and touches nothing when the task isn't in either list", async () => { + useTasksStore.setState({ tasks: [], completedTasks: [] }); + + const ok = await useTasksStore.getState().clearTaskTimeBlock(999); + + expect(ok).toBe(false); + expect(tasksApi.clearTimeBlock).not.toHaveBeenCalled(); + }); +}); + +// setTaskDeadlineAndClearTimeBlock covers #314's all-day-lane drop-table +// row — the one gesture that touches both axes: a Time block dragged into +// the all-day lane sets the Deadline to the drop date and clears the block +// in the same optimistic step, so the block never has a moment to render +// stale before the second write lands. +describe("setTaskDeadlineAndClearTimeBlock", () => { + const blocked = { + ...buyMilk, + due: null, + start: new Date(2026, 8, 15, 14, 0), + durationMinutes: 60, + }; + + it("paints the new Deadline and a cleared Time block immediately, before either API call resolves", () => { + useTasksStore.setState({ tasks: [blocked], completedTasks: [] }); + const due = new Date(2026, 8, 18); + let resolveSet: () => void = () => {}; + vi.mocked(tasksApi.setDeadline).mockReturnValue( + new Promise((resolve) => { + resolveSet = () => resolve({ ...blocked, due, start: null, durationMinutes: null }); + }), + ); + vi.mocked(tasksApi.clearTimeBlock).mockResolvedValue({ ...blocked, due, start: null, durationMinutes: null }); + + const promise = useTasksStore.getState().setTaskDeadlineAndClearTimeBlock(blocked.id, due); + + expect(useTasksStore.getState().tasks).toEqual([{ ...blocked, due, start: null, durationMinutes: null }]); + + resolveSet(); + return promise; + }); + + it("dispatches clearTimeBlock before setDeadline, so a dropped second call never leaves the stale block on the server alongside the new Deadline", async () => { + useTasksStore.setState({ tasks: [blocked], completedTasks: [] }); + const due = new Date(2026, 8, 18); + const calls: string[] = []; + vi.mocked(tasksApi.clearTimeBlock).mockImplementation(async () => { + calls.push("clearTimeBlock"); + return { ...blocked, start: null, durationMinutes: null }; + }); + vi.mocked(tasksApi.setDeadline).mockImplementation(async () => { + calls.push("setDeadline"); + return { ...blocked, due }; + }); + + await useTasksStore.getState().setTaskDeadlineAndClearTimeBlock(blocked.id, due); + + expect(calls).toEqual(["clearTimeBlock", "setDeadline"]); + expect(tasksApi.setDeadline).toHaveBeenCalledWith("token-123", blocked.id, due); + expect(tasksApi.clearTimeBlock).toHaveBeenCalledWith("token-123", blocked.id); + }); + + it("rolls back and shows a toast when either API call refuses", async () => { + useTasksStore.setState({ tasks: [blocked], completedTasks: [] }); + const due = new Date(2026, 8, 18); + vi.mocked(tasksApi.setDeadline).mockRejectedValue(new Error("network error")); + + const ok = await useTasksStore.getState().setTaskDeadlineAndClearTimeBlock(blocked.id, due); + + expect(ok).toBe(false); + expect(useTasksStore.getState().tasks).toEqual([blocked]); + expect(toast.error).toHaveBeenCalledWith(`Couldn't reschedule "${blocked.title}".`); + }); + + it("resolves false and touches nothing when the task isn't in either list", async () => { + useTasksStore.setState({ tasks: [], completedTasks: [] }); + + const ok = await useTasksStore.getState().setTaskDeadlineAndClearTimeBlock(999, new Date()); + + expect(ok).toBe(false); + expect(tasksApi.setDeadline).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/tasksStore.ts b/src/lib/tasksStore.ts new file mode 100644 index 0000000..bb394ab --- /dev/null +++ b/src/lib/tasksStore.ts @@ -0,0 +1,250 @@ +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, #311, 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. +// fetchTasks/fetchCompletedTasks/createTask and the detail surface's own +// writes (notes, Deadline, Priority, Task List) all mirror taskListsStore's +// server-first discipline (ADR-0067): the dialog is open to receive a +// failure, so there's nothing to paint ahead of the server. 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<void>; + fetchCompletedTasks: () => Promise<void>; + createTask: (title: string, taskListId: number) => Promise<Task>; + updateTaskNotes: (id: number, notes: string) => Promise<Task>; + setTaskDeadline: (id: number, due: Date) => Promise<Task>; + clearTaskDeadline: (id: number) => Promise<Task>; + setTaskTimeBlock: (id: number, start: Date, durationMinutes: number) => Promise<boolean>; + clearTaskTimeBlock: (id: number) => Promise<boolean>; + setTaskDeadlineAndClearTimeBlock: (id: number, due: Date) => Promise<boolean>; + updateTaskPriority: (id: number, priority: number) => Promise<Task>; + moveTask: (id: number, taskListId: number) => Promise<Task>; + deleteTask: (id: number) => Promise<void>; + setTaskCompleted: (id: number, completed: boolean) => Promise<boolean>; +} + +// 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>, +): 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; +} + +// replaceTask swaps `updated` in for its own id, in whichever of `tasks`/ +// `completedTasks` currently holds it — a detail-surface write never knows +// ahead of time which list its Task lives in. +function replaceTask(set: (fn: (state: TasksState) => Partial<TasksState>) => void, updated: Task) { + set((state) => ({ + tasks: state.tasks.map((t) => (t.id === updated.id ? updated : t)), + completedTasks: state.completedTasks.map((t) => (t.id === updated.id ? updated : t)), + })); +} + +// 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<TasksState>((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; + }, + + // The detail surface's own fields (#311): a Task may be sitting in either + // `tasks` or `completedTasks` depending on whether it's done, so each + // write replaces it in whichever of the two actually holds it, leaving + // the other untouched. + updateTaskNotes: async (id, notes) => { + const updated = await tasksApi.updateNotes(requireAccessToken(), id, notes); + replaceTask(set, updated); + return updated; + }, + + setTaskDeadline: async (id, due) => { + const updated = await tasksApi.setDeadline(requireAccessToken(), id, due); + replaceTask(set, updated); + return updated; + }, + + clearTaskDeadline: async (id) => { + const updated = await tasksApi.clearDeadline(requireAccessToken(), id); + replaceTask(set, updated); + return updated; + }, + + // setTaskTimeBlock is written optimistically (ADR-0067): unlike the detail + // surface's server-first writes, the grid drop *is* the feedback — there's + // no dialog left open to show a failure in, so the block paints + // immediately and rolls back with a toast if the server refuses. Mirrors + // eventsStore.updateEvent's own discipline for the same gesture on an + // Event. + setTaskTimeBlock: async (id, start, durationMinutes) => { + const current = get().tasks.find((t) => t.id === id) ?? get().completedTasks.find((t) => t.id === id); + if (!current) return false; + + const updated: Task = { ...current, start, durationMinutes }; + return write({ + apply: () => replaceTask(set, updated), + revert: () => replaceTask(set, current), + dispatch: async () => { + await tasksApi.setTimeBlock(requireAccessToken(), id, start, durationMinutes); + }, + fallbackMessage: `Couldn't schedule "${current.title}".`, + }); + }, + + // clearTaskTimeBlock is the drop table's "back to panel" row and the + // Task's own "Unschedule" menu item (#314) — both just want the block + // gone. Optimistic for the same reason setTaskTimeBlock is: the grid + // itself (the block disappearing) is the feedback. + clearTaskTimeBlock: async (id) => { + const current = get().tasks.find((t) => t.id === id) ?? get().completedTasks.find((t) => t.id === id); + if (!current) return false; + + const updated: Task = { ...current, start: null, durationMinutes: null }; + return write({ + apply: () => replaceTask(set, updated), + revert: () => replaceTask(set, current), + dispatch: async () => { + await tasksApi.clearTimeBlock(requireAccessToken(), id); + }, + fallbackMessage: `Couldn't unschedule "${current.title}".`, + }); + }, + + // setTaskDeadlineAndClearTimeBlock is the drop table's one row that + // touches both axes at once (#314): a Time block dragged into the all-day + // lane. Written as a single optimistic step — an in-between state where + // the Deadline is set but the old block still shows would have the block + // visibly snap back once the second write lands, which is exactly what + // this row exists to avoid (ADR-0083). The two API calls behind it aren't + // atomic on the server, so they're ordered to fail safe: clearing the + // block first means a dropped second call (setDeadline) leaves the server + // with the block already gone rather than with the new Deadline set + // alongside the stale block precedence would still draw on the grid — + // the one outcome this row exists to make unreachable. + setTaskDeadlineAndClearTimeBlock: async (id, due) => { + const current = get().tasks.find((t) => t.id === id) ?? get().completedTasks.find((t) => t.id === id); + if (!current) return false; + + const updated: Task = { ...current, due, start: null, durationMinutes: null }; + return write({ + apply: () => replaceTask(set, updated), + revert: () => replaceTask(set, current), + dispatch: async () => { + const accessToken = requireAccessToken(); + await tasksApi.clearTimeBlock(accessToken, id); + await tasksApi.setDeadline(accessToken, id, due); + }, + fallbackMessage: `Couldn't reschedule "${current.title}".`, + }); + }, + + updateTaskPriority: async (id, priority) => { + const updated = await tasksApi.updatePriority(requireAccessToken(), id, priority); + replaceTask(set, updated); + return updated; + }, + + moveTask: async (id, taskListId) => { + const updated = await tasksApi.move(requireAccessToken(), id, taskListId); + replaceTask(set, updated); + return updated; + }, + + deleteTask: async (id) => { + await tasksApi.remove(requireAccessToken(), id); + set((state) => ({ + tasks: state.tasks.filter((t) => t.id !== id), + completedTasks: state.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.", + }); + }, +})); diff --git a/src/lib/timeFormat.test.ts b/src/lib/timeFormat.test.ts index d2157a5..2af4172 100644 --- a/src/lib/timeFormat.test.ts +++ b/src/lib/timeFormat.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { formatDateTime, timePattern } from "./timeFormat"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { detectBrowserTimeFormat, formatDateTime, timePattern } from "./timeFormat"; describe("timePattern", () => { it("returns the 12-hour date-fns pattern for 12h", () => { @@ -25,3 +25,36 @@ describe("formatDateTime", () => { expect(formatDateTime(moment, "24h")).toBe("Aug 20, 2026, 18:47"); }); }); + +// #324: the public Booking Link page has no Session and so no Preference to +// read a Time format from — it seeds from the browser's own locale instead, +// the same way timezones.ts' detectBrowserTimeZone seeds the viewer zone. +describe("detectBrowserTimeFormat", () => { + const originalDateTimeFormat = Intl.DateTimeFormat; + afterEach(() => { + Intl.DateTimeFormat = originalDateTimeFormat; + }); + + function mockHourCycle(hourCycle: string) { + vi.spyOn(Intl, "DateTimeFormat").mockImplementation(function () { + return { resolvedOptions: () => ({ hourCycle }) } as unknown as Intl.DateTimeFormat; + }); + } + + it("returns 24h when the locale's resolved hourCycle is h23", () => { + mockHourCycle("h23"); + expect(detectBrowserTimeFormat()).toBe("24h"); + }); + + it("returns 12h when the locale's resolved hourCycle is h12", () => { + mockHourCycle("h12"); + expect(detectBrowserTimeFormat()).toBe("12h"); + }); + + it("falls back to 12h when Intl throws", () => { + vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => { + throw new Error("unsupported"); + }); + expect(detectBrowserTimeFormat()).toBe("12h"); + }); +}); diff --git a/src/lib/timeFormat.ts b/src/lib/timeFormat.ts index fe46324..e779c5a 100644 --- a/src/lib/timeFormat.ts +++ b/src/lib/timeFormat.ts @@ -19,3 +19,20 @@ export function timePattern(timeFormat: TimeFormat): string { export function formatDateTime(date: Date, timeFormat: TimeFormat): string { return format(date, `MMM d, yyyy, ${timePattern(timeFormat)}`); } + +// The visitor's own browser-locale 12h/24h preference, seeded once for the +// public Booking Link page (#324) the same "detected from browser, +// correctable on the page" way its viewer timezone is (timezones.ts' +// detectBrowserTimeZone) — this app has no Session there to read a +// Preference from. hourCycle is what Intl actually resolves a locale's +// preferred clock to; hour12 alone can't distinguish h11/h12 from h23/h24, +// but either half of that split maps onto the same 12h/24h choice this app +// offers. +export function detectBrowserTimeFormat(): TimeFormat { + try { + const { hourCycle } = new Intl.DateTimeFormat(undefined, { hour: "numeric" }).resolvedOptions(); + return hourCycle === "h23" || hourCycle === "h24" ? "24h" : "12h"; + } catch { + return "12h"; + } +} diff --git a/src/lib/timezones.ts b/src/lib/timezones.ts new file mode 100644 index 0000000..c6124d0 --- /dev/null +++ b/src/lib/timezones.ts @@ -0,0 +1,53 @@ +// A curated fallback used only when the runtime lacks +// Intl.supportedValuesOf (older Safari) — every evergreen browser this app +// otherwise targets has it. +const FALLBACK_TIME_ZONES = [ + "Etc/UTC", + "America/New_York", + "America/Chicago", + "America/Denver", + "America/Los_Angeles", + "America/Sao_Paulo", + "Europe/London", + "Europe/Berlin", + "Europe/Sarajevo", + "Europe/Moscow", + "Africa/Cairo", + "Asia/Dubai", + "Asia/Kolkata", + "Asia/Shanghai", + "Asia/Tokyo", + "Australia/Sydney", + "Pacific/Auckland", +]; + +interface IntlWithSupportedValuesOf { + supportedValuesOf?: (key: "timeZone") => string[]; +} + +// Every IANA timezone name the runtime knows about, for the Availability +// Schedule editor's timezone picker (#320, ADR-0085) — nothing in this +// codebase already lists timezones, since Anchor zone (ADR-0019) isn't +// editable from the web app's own UI today. +export function listTimeZones(): string[] { + try { + const values = (Intl as IntlWithSupportedValuesOf).supportedValuesOf?.("timeZone"); + if (values && values.length > 0) return values; + } catch { + // Fall through to the curated fallback below. + } + return FALLBACK_TIME_ZONES; +} + +// The caller's own browser-detected IANA zone — used only to seed the +// Default Availability Schedule's timezone the one time it doesn't exist +// yet (#320, ADR-0085), the same "detected from browser, correctable on the +// page" pattern CONTEXT.md's Availability Schedule entry describes for a +// Booking Link visitor. +export function detectBrowserTimeZone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return "Etc/UTC"; + } +} 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<typeof import("./workspacesApi")>("./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<WorkspacesState>((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); }, })); diff --git a/src/settings/AccountSection.test.tsx b/src/settings/AccountSection.test.tsx index e02646d..e64b246 100644 --- a/src/settings/AccountSection.test.tsx +++ b/src/settings/AccountSection.test.tsx @@ -16,6 +16,8 @@ vi.mock("../lib/authApi", async () => { me: vi.fn(), updateEmail: vi.fn(), updateName: vi.fn(), + updateHandle: vi.fn(), + getHandleSuggestion: vi.fn(), }, }; }); @@ -42,6 +44,7 @@ const user = { timeFormat: "24h" as const, workingHoursStart: null, workingHoursEnd: null, + handle: null, }; beforeEach(() => { @@ -52,6 +55,9 @@ beforeEach(() => { pendingEmail: null, accessToken: "token-123", }); + // A default every test can rely on unless it overrides it — AccountSection + // fetches this unconditionally on mount whenever user.handle is null. + vi.mocked(authApi.getHandleSuggestion).mockResolvedValue("ada-2"); }); async function fillPasswordForm(current: string, next: string, confirm: string) { @@ -320,3 +326,70 @@ describe("AccountSection — change password (#234)", () => { expect(screen.getByText("Passwords don't match.")).toBeInTheDocument(); }); }); + +// #321, ADR-0084. +describe("AccountSection — Handle", () => { + it("pre-fills the field with a fetched suggestion for a User with no Handle yet", async () => { + render(<AccountSection />); + + expect(await screen.findByLabelText("Handle")).toHaveValue("ada-2"); + }); + + it("claims a Handle with no warning, since nothing was published under nothing", async () => { + const confirmSpy = vi.spyOn(window, "confirm"); + vi.mocked(authApi.updateHandle).mockResolvedValue({ ...user, handle: "ada" }); + render(<AccountSection />); + + const input = await screen.findByLabelText("Handle"); + await userEvent.clear(input); + await userEvent.type(input, "ada"); + await userEvent.click(screen.getAllByRole("button", { name: "Save" })[2]); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(authApi.updateHandle).toHaveBeenCalledWith("token-123", "ada"); + expect(await screen.findByText("Saved.")).toBeInTheDocument(); + }); + + it("warns before renaming an already-claimed Handle, and proceeds when confirmed", async () => { + useAuthStore.setState({ user: { ...user, handle: "ada" } }); + vi.spyOn(window, "confirm").mockReturnValue(true); + vi.mocked(authApi.updateHandle).mockResolvedValue({ ...user, handle: "adalovelace" }); + render(<AccountSection />); + + const input = screen.getByLabelText("Handle"); + await userEvent.clear(input); + await userEvent.type(input, "adalovelace"); + await userEvent.click(screen.getAllByRole("button", { name: "Save" })[2]); + + expect(window.confirm).toHaveBeenCalledWith( + expect.stringContaining('breaks every URL published under "ada"'), + ); + expect(authApi.updateHandle).toHaveBeenCalledWith("token-123", "adalovelace"); + }); + + it("does nothing when the rename warning is declined", async () => { + useAuthStore.setState({ user: { ...user, handle: "ada" } }); + vi.spyOn(window, "confirm").mockReturnValue(false); + render(<AccountSection />); + + const input = screen.getByLabelText("Handle"); + await userEvent.clear(input); + await userEvent.type(input, "adalovelace"); + await userEvent.click(screen.getAllByRole("button", { name: "Save" })[2]); + + expect(authApi.updateHandle).not.toHaveBeenCalled(); + expect(input).toHaveValue("adalovelace"); + }); + + it("surfaces a taken-handle conflict inline", async () => { + vi.mocked(authApi.updateHandle).mockRejectedValue(new Error("handle is already taken")); + render(<AccountSection />); + + const input = await screen.findByLabelText("Handle"); + await userEvent.clear(input); + await userEvent.type(input, "bob"); + await userEvent.click(screen.getAllByRole("button", { name: "Save" })[2]); + + expect(await screen.findByText("handle is already taken")).toBeInTheDocument(); + }); +}); diff --git a/src/settings/AccountSection.tsx b/src/settings/AccountSection.tsx index 8a5a4a7..66a7440 100644 --- a/src/settings/AccountSection.tsx +++ b/src/settings/AccountSection.tsx @@ -1,5 +1,6 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useAuthStore } from "../lib/authStore"; +import { authApi } from "../lib/authApi"; import { appPasswordsApi } from "../lib/appPasswordsApi"; import { useAsyncAction } from "../hooks/useAsyncAction"; import { useSyncedField } from "../hooks/useSyncedField"; @@ -23,6 +24,7 @@ export function AccountSection() { const accessToken = useAuthStore((state) => state.accessToken); const updateName = useAuthStore((state) => state.updateName); const updateEmail = useAuthStore((state) => state.updateEmail); + const updateHandle = useAuthStore((state) => state.updateHandle); const changePassword = useAuthStore((state) => state.changePassword); const disableAccount = useAuthStore((state) => state.disableAccount); @@ -41,6 +43,32 @@ export function AccountSection() { const email = useSyncedField(user?.email ?? "", () => setEmailSaved(false)); const emailAction = useAsyncAction(); + const [handleSaved, setHandleSaved] = useState(false); + const handle = useSyncedField(user?.handle ?? "", () => setHandleSaved(false)); + const handleAction = useAsyncAction(); + + // A Handle suggestion (#321, ADR-0084), fetched once for a User who + // hasn't claimed one yet — pre-filling the field rather than only showing + // it as a placeholder, so accepting it is a single Save click. Only + // applied if the field is still empty by the time it arrives, so a User + // who starts typing before the round trip resolves is never overwritten. + useEffect(() => { + if (user?.handle || !accessToken) return; + authApi + .getHandleSuggestion(accessToken) + .then((suggestion) => { + handle.setValue((current) => (current === "" ? suggestion : current)); + }) + .catch(() => { + // A failed suggestion just leaves the field empty — claiming a + // Handle by typing one in directly still works. + }); + // Runs once, right after this Section mounts with no Handle yet; + // handle.setValue's identity isn't stable across renders and re-running + // this on every render would refetch on every keystroke. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); @@ -53,6 +81,7 @@ export function AccountSection() { const isNameUnchanged = name.value === (user?.name ?? ""); const isEmailUnchanged = email.value === (user?.email ?? ""); + const isHandleUnchanged = handle.value === (user?.handle ?? ""); // Both sides of the mismatch check share the same non-empty rule // canSubmitPassword applies, so there's no whitespace-only value that @@ -105,6 +134,30 @@ export function AccountSection() { }); } + async function handleSubmitHandle(domEvent: React.FormEvent) { + domEvent.preventDefault(); + if (isHandleUnchanged || !handle.value.trim()) return; + + await handleAction.run(async () => { + setHandleSaved(false); + + // Renaming (as opposed to a first claim) breaks every URL published + // under the old Handle, with nothing forwarding (ADR-0084) — only + // worth interrupting for when there's an old Handle to lose. + if (user?.handle) { + const proceed = window.confirm( + `Changing your handle breaks every URL published under "${user.handle}" — nothing ` + + `forwards, and this cannot be undone.`, + ); + if (!proceed) return; + } + + const updatedUser = await updateHandle(handle.value); + handle.markSaved(updatedUser.handle ?? ""); + setHandleSaved(true); + }); + } + async function handleSubmitPassword(domEvent: React.FormEvent) { domEvent.preventDefault(); if (!canSubmitPassword) return; @@ -192,6 +245,34 @@ export function AccountSection() { <p className="mt-2 text-label-sm text-ink-muted">Saved.</p> )} + <p className="mt-6 text-body text-ink-muted"> + Your public name — unique, lowercase, and safe to show a stranger. Absent until you claim + one. + </p> + <form onSubmit={handleSubmitHandle} className="mt-2 flex items-end gap-2"> + <Input + label="Handle" + value={handle.value} + onChange={(domEvent) => { + handle.setValue(domEvent.target.value); + setHandleSaved(false); + }} + disabled={handleAction.isSubmitting} + className="w-72" + /> + <Button + type="submit" + disabled={isHandleUnchanged || !handle.value.trim()} + loading={handleAction.isSubmitting} + > + Save + </Button> + </form> + {handleAction.error && <p className="mt-2 text-label-sm text-danger">{handleAction.error}</p>} + {handleSaved && !handleAction.error && ( + <p className="mt-2 text-label-sm text-ink-muted">Saved.</p> + )} + {user?.emailReminderChannelAvailable && !user.invitationRepliesConfigured && ( <p className="mt-2 text-label-sm text-ink-muted"> Invitations are emailed to Attendees, but this instance has no IMAP configured, so 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<string | null>(null); const [copied, setCopied] = useState(false); const [revokingId, setRevokingId] = useState<number | null>(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. </p> )} + {nudgeExposure && ( + <p className="mt-2 text-label-sm text-ink-muted"> + 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. + </p> + )} </div> )} diff --git a/src/settings/AvailabilityScheduleDialog.tsx b/src/settings/AvailabilityScheduleDialog.tsx new file mode 100644 index 0000000..e5128f6 --- /dev/null +++ b/src/settings/AvailabilityScheduleDialog.tsx @@ -0,0 +1,219 @@ +import { useState } from "react"; +import { Dialog } from "@base-ui/react/dialog"; +import { Plus, Trash2 } from "lucide-react"; +import { Button } from "../components/ui/Button"; +import { buttonClasses } from "../components/ui/buttonClasses"; +import { IconButton } from "../components/ui/IconButton"; +import { Input } from "../components/ui/Input"; +import { Select } from "../components/ui/Select"; +import { useAsyncAction } from "../hooks/useAsyncAction"; +import { useAvailabilitySchedulesStore } from "../lib/availabilitySchedulesStore"; +import type { AvailabilityRange, AvailabilitySchedule } from "../lib/availabilitySchedulesApi"; +import { listTimeZones } from "../lib/timezones"; + +const WEEKDAY_LABELS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +// <input type="time"> emits/expects "HH:mm" — minutes-since-midnight is the +// wire/store unit, the same convention PreferencesSection's Working hours +// pair uses. +function minutesToTimeString(minutes: number): string { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`; +} + +function timeStringToMinutes(time: string): number { + const [hours, minutes] = time.split(":").map(Number); + return hours * 60 + minutes; +} + +// A range as edited in this dialog, before it has an id of its own — key is +// local-only, for React's list identity, and is never sent to the server. +interface DraftRange extends AvailabilityRange { + key: string; +} + +let nextDraftKey = 0; +function newDraftKey(): string { + nextDraftKey += 1; + return `draft-${nextDraftKey}`; +} + +function toDraftRanges(ranges: AvailabilityRange[]): DraftRange[] { + return ranges.map((range) => ({ ...range, key: newDraftKey() })); +} + +const TIME_ZONE_OPTIONS = listTimeZones().map((tz) => ({ value: tz, label: tz })); + +interface AvailabilityScheduleDialogProps { + // Absent for "create a new schedule"; present for "edit this one". + schedule?: AvailabilitySchedule; + initialTzid: string; + onClose: () => void; +} + +// The Availability Schedule editor (#320, ADR-0085): name, IANA timezone, +// and the weekly pattern of ranges a Booking Link's slots will later be +// derived from (#323). Several ranges per weekday are allowed, so a split +// day (mornings and late afternoons) is representable. Edits the whole +// Schedule as one form — Save replaces name, timezone and the entire range +// set at once, matching AvailabilityScheduleService.Update's own wholesale +// replace. +export function AvailabilityScheduleDialog({ schedule, initialTzid, onClose }: AvailabilityScheduleDialogProps) { + const createSchedule = useAvailabilitySchedulesStore((state) => state.createSchedule); + const updateSchedule = useAvailabilitySchedulesStore((state) => state.updateSchedule); + + const [name, setName] = useState(schedule?.name ?? ""); + const [tzid, setTzid] = useState(schedule?.tzid ?? initialTzid); + const [ranges, setRanges] = useState<DraftRange[]>(() => toDraftRanges(schedule?.ranges ?? [])); + const { isSubmitting, error, run } = useAsyncAction(); + + const isEditing = schedule !== undefined; + const invalidRangeKeys = new Set(ranges.filter((r) => r.startMinute >= r.endMinute).map((r) => r.key)); + const canSave = name.trim() !== "" && invalidRangeKeys.size === 0; + + function addRange(weekday: number) { + setRanges((current) => [...current, { key: newDraftKey(), weekday, startMinute: 9 * 60, endMinute: 17 * 60 }]); + } + + function removeRange(key: string) { + setRanges((current) => current.filter((r) => r.key !== key)); + } + + function updateRange(key: string, field: "startMinute" | "endMinute", minutes: number) { + setRanges((current) => current.map((r) => (r.key === key ? { ...r, [field]: minutes } : r))); + } + + async function handleSave() { + if (!canSave) return; + + const payload: AvailabilityRange[] = ranges.map(({ weekday, startMinute, endMinute }) => ({ + weekday, + startMinute, + endMinute, + })); + + await run(async () => { + if (isEditing) { + await updateSchedule(schedule.id, name.trim(), tzid, payload); + } else { + await createSchedule(name.trim(), tzid, payload); + } + onClose(); + }); + } + + return ( + <Dialog.Root + open + onOpenChange={(open) => { + if (!open && !isSubmitting) onClose(); + }} + > + <Dialog.Portal> + <Dialog.Backdrop className="fixed inset-0 z-40 bg-ink/20" /> + <Dialog.Popup className="fixed top-1/2 left-1/2 z-50 max-h-[85vh] w-[30rem] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-shell-lg bg-surface p-5 shadow-elevation-3"> + <Dialog.Title className="text-heading font-medium text-ink"> + {isEditing ? `Edit ${schedule.name}` : "New availability schedule"} + </Dialog.Title> + <Dialog.Description className="mt-1 text-body text-ink-muted"> + When you're willing to be booked, separate from your working hours shading. + </Dialog.Description> + + <div className="mt-4 flex flex-col gap-4"> + <Input label="Name" placeholder="Default" value={name} onChange={(e) => setName(e.target.value)} /> + + <Select + label="Timezone" + value={tzid} + onValueChange={setTzid} + options={TIME_ZONE_OPTIONS} + /> + + <div> + <p className="mb-2 text-label-sm font-medium text-ink">Weekly hours</p> + <div className="flex flex-col gap-3"> + {WEEKDAY_LABELS.map((label, weekday) => { + const dayRanges = ranges.filter((r) => r.weekday === weekday); + return ( + <div key={weekday}> + <div className="flex items-center justify-between"> + <p className="text-body text-ink">{label}</p> + <IconButton + size="small" + aria-label={`Add a range on ${label}`} + onClick={() => addRange(weekday)} + > + <Plus className="size-4" /> + </IconButton> + </div> + {dayRanges.length === 0 ? ( + <p className="mt-1 text-label-sm text-ink-muted">Unavailable</p> + ) : ( + <ul className="mt-1 flex flex-col gap-1.5"> + {dayRanges.map((range) => ( + <li key={range.key} className="flex items-center gap-2"> + <Input + aria-label={`${label} range start`} + type="time" + size="small" + value={minutesToTimeString(range.startMinute)} + invalid={invalidRangeKeys.has(range.key)} + onChange={(e) => updateRange(range.key, "startMinute", timeStringToMinutes(e.target.value))} + className="flex-1" + /> + <Input + aria-label={`${label} range end`} + type="time" + size="small" + value={minutesToTimeString(range.endMinute)} + invalid={invalidRangeKeys.has(range.key)} + onChange={(e) => updateRange(range.key, "endMinute", timeStringToMinutes(e.target.value))} + className="flex-1" + /> + <IconButton + size="small" + color="danger" + aria-label={`Remove this range on ${label}`} + onClick={() => removeRange(range.key)} + > + <Trash2 className="size-4" /> + </IconButton> + </li> + ))} + </ul> + )} + </div> + ); + })} + </div> + {ranges.length === 0 && ( + <p className="mt-2 text-label-sm text-ink-muted"> + No ranges yet — this schedule will offer no slots until you add one. + </p> + )} + </div> + </div> + + {error && ( + <p className="mt-3 text-label-sm text-danger" role="alert"> + {error} + </p> + )} + + <div className="mt-5 flex justify-end gap-2"> + <Dialog.Close + className={buttonClasses({ variant: "outline", color: "secondary", size: "small" })} + disabled={isSubmitting} + > + Cancel + </Dialog.Close> + <Button size="small" loading={isSubmitting} disabled={!canSave} onClick={handleSave}> + Save + </Button> + </div> + </Dialog.Popup> + </Dialog.Portal> + </Dialog.Root> + ); +} diff --git a/src/settings/AvailabilitySchedulesSection.test.tsx b/src/settings/AvailabilitySchedulesSection.test.tsx new file mode 100644 index 0000000..8bbf562 --- /dev/null +++ b/src/settings/AvailabilitySchedulesSection.test.tsx @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// Same convention as ConnectionsSection.test.tsx: the *Api module is mocked, +// the store is real — this covers the Settings section's wiring (#320, +// ADR-0085), not the request/response shape itself. +vi.mock("../lib/availabilitySchedulesApi", async () => { + const actual = await vi.importActual<typeof import("../lib/availabilitySchedulesApi")>( + "../lib/availabilitySchedulesApi", + ); + return { + ...actual, + availabilitySchedulesApi: { + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + }, + }; +}); + +const { availabilitySchedulesApi } = await import("../lib/availabilitySchedulesApi"); +const { useAuthStore } = await import("../lib/authStore"); +const { useAvailabilitySchedulesStore } = await import("../lib/availabilitySchedulesStore"); +const { AvailabilitySchedulesSection } = await import("./AvailabilitySchedulesSection"); + +const defaultSchedule = { + id: 1, + name: "Default", + tzid: "Etc/UTC", + ranges: [ + { weekday: 1, startMinute: 9 * 60, endMinute: 17 * 60 }, + { weekday: 2, startMinute: 9 * 60, endMinute: 17 * 60 }, + ], +}; + +beforeEach(() => { + vi.clearAllMocks(); + useAuthStore.setState({ status: "authenticated", accessToken: "token-123" }); + useAvailabilitySchedulesStore.setState({ schedules: [] }); + vi.mocked(availabilitySchedulesApi.list).mockResolvedValue([]); +}); + +describe("AvailabilitySchedulesSection — listing", () => { + it("shows a placeholder when there are no schedules", async () => { + render(<AvailabilitySchedulesSection />); + + expect(await screen.findByText("No availability schedules yet.")).toBeInTheDocument(); + }); + + it("lists a schedule with its timezone and per-weekday ranges", async () => { + vi.mocked(availabilitySchedulesApi.list).mockResolvedValue([defaultSchedule]); + render(<AvailabilitySchedulesSection />); + + expect(await screen.findByText("Default")).toBeInTheDocument(); + expect(screen.getByText("Etc/UTC")).toBeInTheDocument(); + expect(screen.getByText("Mon")).toBeInTheDocument(); + expect(screen.getByText("Tue")).toBeInTheDocument(); + }); + + it("shows an empty-schedule notice for one with no ranges", async () => { + vi.mocked(availabilitySchedulesApi.list).mockResolvedValue([{ ...defaultSchedule, ranges: [] }]); + render(<AvailabilitySchedulesSection />); + + expect( + await screen.findByText("No slots yet — this schedule offers nothing to book."), + ).toBeInTheDocument(); + }); +}); + +describe("AvailabilitySchedulesSection — creating", () => { + it("creates a new schedule from the dialog", async () => { + render(<AvailabilitySchedulesSection />); + await waitFor(() => expect(availabilitySchedulesApi.list).toHaveBeenCalled()); + + vi.mocked(availabilitySchedulesApi.create).mockResolvedValue({ + id: 2, + name: "Evenings", + tzid: "Etc/UTC", + ranges: [], + }); + + await userEvent.click(await screen.findByRole("button", { name: "New schedule" })); + await userEvent.type(await screen.findByLabelText("Name"), "Evenings"); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => + expect(availabilitySchedulesApi.create).toHaveBeenCalledWith("token-123", "Evenings", expect.any(String), []), + ); + expect(await screen.findByText("Evenings")).toBeInTheDocument(); + }); + + it("disables Save while any range has an end time at or before its start time", async () => { + render(<AvailabilitySchedulesSection />); + await userEvent.click(await screen.findByRole("button", { name: "New schedule" })); + await userEvent.type(await screen.findByLabelText("Name"), "Bad range"); + + await userEvent.click(screen.getByRole("button", { name: "Add a range on Monday" })); + fireEvent.change(screen.getByLabelText("Monday range start"), { target: { value: "17:00" } }); + fireEvent.change(screen.getByLabelText("Monday range end"), { target: { value: "09:00" } }); + + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); +}); + +describe("AvailabilitySchedulesSection — deleting", () => { + it("deletes a schedule after confirming", async () => { + vi.mocked(availabilitySchedulesApi.list).mockResolvedValue([defaultSchedule]); + vi.mocked(availabilitySchedulesApi.remove).mockResolvedValue(undefined); + render(<AvailabilitySchedulesSection />); + + await userEvent.click(await screen.findByRole("button", { name: "Delete Default" })); + expect(await screen.findByText("Delete Default?")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Delete schedule" })); + + await waitFor(() => expect(availabilitySchedulesApi.remove).toHaveBeenCalledWith("token-123", 1)); + await waitFor(() => expect(screen.getByText("No availability schedules yet.")).toBeInTheDocument()); + }); + + it("does nothing when the delete dialog is cancelled", async () => { + vi.mocked(availabilitySchedulesApi.list).mockResolvedValue([defaultSchedule]); + render(<AvailabilitySchedulesSection />); + + await userEvent.click(await screen.findByRole("button", { name: "Delete Default" })); + await userEvent.click(await screen.findByRole("button", { name: "Cancel" })); + + expect(availabilitySchedulesApi.remove).not.toHaveBeenCalled(); + expect(screen.getByText("Default")).toBeInTheDocument(); + }); +}); diff --git a/src/settings/AvailabilitySchedulesSection.tsx b/src/settings/AvailabilitySchedulesSection.tsx new file mode 100644 index 0000000..ef6cddb --- /dev/null +++ b/src/settings/AvailabilitySchedulesSection.tsx @@ -0,0 +1,130 @@ +import { useEffect, useState } from "react"; +import { format } from "date-fns"; +import { Pencil, Plus, Trash2 } from "lucide-react"; +import { Button } from "../components/ui/Button"; +import { IconButton } from "../components/ui/IconButton"; +import { useTimePattern } from "../hooks/useTimePattern"; +import { errorMessage } from "../lib/errorMessage"; +import { useAvailabilitySchedulesStore } from "../lib/availabilitySchedulesStore"; +import { detectBrowserTimeZone } from "../lib/timezones"; +import type { AvailabilityRange, AvailabilitySchedule } from "../lib/availabilitySchedulesApi"; +import { AvailabilityScheduleDialog } from "./AvailabilityScheduleDialog"; +import { DeleteAvailabilityScheduleDialog } from "./DeleteAvailabilityScheduleDialog"; + +const WEEKDAY_SHORT_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +function formatMinuteOfDay(minutes: number, pattern: string): string { + const date = new Date(2000, 0, 1, Math.floor(minutes / 60), minutes % 60); + return format(date, pattern); +} + +function groupByWeekday(ranges: AvailabilityRange[]): [number, AvailabilityRange[]][] { + const byWeekday = new Map<number, AvailabilityRange[]>(); + for (const range of ranges) { + const existing = byWeekday.get(range.weekday) ?? []; + existing.push(range); + byWeekday.set(range.weekday, existing); + } + return Array.from(byWeekday.entries()).sort(([a], [b]) => a - b); +} + +// The Availability Schedules Settings Section (#320, ADR-0085): a named +// weekly pattern of time ranges a User is willing to be booked on, separate +// from Working hours' grid-shading (PreferencesSection). Sits in Settings' +// Personal group beside Calendar sets — private outright, no sharing +// mechanism, no Role. The backend seeds a "Default" schedule the first time +// this section asks for the caller's schedules and finds none, copying it +// from Working hours (or Mon-Fri 09:00-17:00 when that's unset) and taking +// this browser's own detected timezone; editing Working hours afterwards +// never reaches it again. +export function AvailabilitySchedulesSection() { + const schedules = useAvailabilitySchedulesStore((state) => state.schedules); + const fetchSchedules = useAvailabilitySchedulesStore((state) => state.fetchSchedules); + const timePattern = useTimePattern(); + + const [loadError, setLoadError] = useState<string | null>(null); + const [isCreating, setIsCreating] = useState(false); + const [editingTarget, setEditingTarget] = useState<AvailabilitySchedule | null>(null); + const [deleteTarget, setDeleteTarget] = useState<AvailabilitySchedule | null>(null); + + useEffect(() => { + fetchSchedules(detectBrowserTimeZone()) + .then(() => setLoadError(null)) + .catch((err) => setLoadError(errorMessage(err))); + }, [fetchSchedules]); + + return ( + <section> + <h2 className="text-heading font-medium text-ink">Availability schedules</h2> + <p className="mt-1 text-body text-ink-muted"> + When you're willing to be booked — separate from Working hours, which only shades the grid. Reusable + across your booking links. + </p> + + {loadError && <p className="mt-2 text-label-sm text-danger">{loadError}</p>} + + <div className="mt-4"> + <Button size="small" leadingIcon={<Plus className="size-4" />} onClick={() => setIsCreating(true)}> + New schedule + </Button> + </div> + + <ul className="mt-4 flex flex-col gap-2"> + {schedules.map((schedule) => { + const groups = groupByWeekday(schedule.ranges); + return ( + <li key={schedule.id} className="rounded-md border border-border px-3 py-2"> + <div className="flex items-start justify-between gap-2"> + <div className="min-w-0"> + <p className="text-body font-medium text-ink">{schedule.name}</p> + <p className="text-label-sm text-ink-muted">{schedule.tzid}</p> + </div> + <div className="flex items-center gap-1"> + <IconButton aria-label={`Edit ${schedule.name}`} onClick={() => setEditingTarget(schedule)}> + <Pencil className="size-4" /> + </IconButton> + <IconButton aria-label={`Delete ${schedule.name}`} onClick={() => setDeleteTarget(schedule)}> + <Trash2 className="size-4" /> + </IconButton> + </div> + </div> + + {groups.length === 0 ? ( + <p className="mt-2 text-label-sm text-ink-muted">No slots yet — this schedule offers nothing to book.</p> + ) : ( + <ul className="mt-2 flex flex-col gap-0.5"> + {groups.map(([weekday, dayRanges]) => ( + <li key={weekday} className="text-label-sm text-ink-muted"> + <span className="font-medium text-ink">{WEEKDAY_SHORT_LABELS[weekday]}</span>{" "} + {dayRanges + .map((r) => `${formatMinuteOfDay(r.startMinute, timePattern)}–${formatMinuteOfDay(r.endMinute, timePattern)}`) + .join(", ")} + </li> + ))} + </ul> + )} + </li> + ); + })} + + {schedules.length === 0 && !loadError && ( + <p className="text-label-sm text-ink-muted">No availability schedules yet.</p> + )} + </ul> + + {isCreating && ( + <AvailabilityScheduleDialog initialTzid={detectBrowserTimeZone()} onClose={() => setIsCreating(false)} /> + )} + {editingTarget && ( + <AvailabilityScheduleDialog + schedule={editingTarget} + initialTzid={editingTarget.tzid} + onClose={() => setEditingTarget(null)} + /> + )} + {deleteTarget && ( + <DeleteAvailabilityScheduleDialog schedule={deleteTarget} onClose={() => setDeleteTarget(null)} /> + )} + </section> + ); +} diff --git a/src/settings/CalendarPickerModal.test.tsx b/src/settings/CalendarPickerModal.test.tsx index f3d9874..024de27 100644 --- a/src/settings/CalendarPickerModal.test.tsx +++ b/src/settings/CalendarPickerModal.test.tsx @@ -15,14 +15,31 @@ 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<typeof import("../lib/calendarSetsApi")>( + "../lib/calendarSetsApi", + ); + return { + ...actual, + calendarSetsApi: { ...actual.calendarSetsApi, addCalendar: vi.fn(async () => undefined) }, + }; +}); 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 { 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", @@ -48,8 +65,12 @@ beforeEach(() => { vi.clearAllMocks(); useAuthStore.setState({ accessToken: "token-123" }); 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([]); }); afterEach(() => { @@ -82,6 +103,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(<CalendarPickerModal connectionId={1} onClose={vi.fn()} />); + + 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(<CalendarPickerModal connectionId={1} onClose={vi.fn()} />); + + 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([]); @@ -204,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(<CalendarPickerModal connectionId={1} onClose={vi.fn()} />); + + 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(<CalendarPickerModal connectionId={1} onClose={vi.fn()} />); + + 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(<CalendarPickerModal connectionId={1} onClose={vi.fn()} />); + + 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(<CalendarPickerModal connectionId={1} onClose={vi.fn()} />); + + 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 0ada96a..f84b5f8 100644 --- a/src/settings/CalendarPickerModal.tsx +++ b/src/settings/CalendarPickerModal.tsx @@ -1,11 +1,15 @@ import { useEffect, useMemo, useState } from "react"; import { Dialog } from "@base-ui/react/dialog"; -import { useCalendarsStore } from "../lib/calendarsStore"; +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"; 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"; @@ -31,7 +35,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 @@ -39,6 +43,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<PickerCalendar[] | null>(null); const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set()); @@ -154,13 +164,28 @@ 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/ // 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)); @@ -229,6 +254,12 @@ export function CalendarPickerModal({ connectionId, onClose }: CalendarPickerMod </ul> )} + <AddToActiveSetCheckbox + activeCalendarSet={activeCalendarSet} + checked={addToActiveSet} + onCheckedChange={setAddToActiveSet} + /> + <div className="mt-5 flex justify-end gap-2"> <Dialog.Close className={buttonClasses({ variant: "outline", color: "secondary", size: "small" })} 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<string | null>(null); + const [actionError, setActionError] = useState<string | null>(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 ( + <li key={calendar.id} className="flex items-center justify-between gap-2 rounded-md px-2 py-1.5"> + <span className="min-w-0 truncate text-body text-ink">{calendar.name}</span> + <Checkbox + checked={isMember} + onCheckedChange={() => handleToggle(calendar, isMember)} + disabled={busyCalendarId === calendar.id} + aria-label={ + isMember + ? `Remove ${calendar.name} from ${calendarSet.name}` + : `Add ${calendar.name} to ${calendarSet.name}` + } + /> + </li> + ); + } + + function renderGroup(label: string, list: Calendar[]) { + if (list.length === 0) return null; + return ( + <div key={label} className="mt-3 first:mt-0"> + <p className="px-2 text-label-sm font-medium text-ink-muted">{label}</p> + <ul className="mt-1">{list.map(renderRow)}</ul> + </div> + ); + } + + return ( + <Dialog.Root open onOpenChange={(open) => !open && onClose()}> + <Dialog.Portal> + <Dialog.Backdrop className="fixed inset-0 z-40 bg-ink/20" /> + <Dialog.Popup className="fixed top-1/2 left-1/2 z-50 w-96 max-h-[85vh] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-shell-lg bg-surface p-5 shadow-elevation-3"> + <Dialog.Title className="text-heading font-medium text-ink">{calendarSet.name}</Dialog.Title> + <Dialog.Description className="mt-1 text-body text-ink-muted"> + Choose which of this workspace's calendars belong to this set. + </Dialog.Description> + + {actionError && ( + <p className="mt-2 text-label-sm text-danger" role="alert"> + {actionError} + </p> + )} + + <div className="mt-4"> + {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)} + </div> + + {calendars.length === 0 && ( + <p className="mt-2 text-label-sm text-ink-muted">No calendars in this workspace yet.</p> + )} + + <div className="mt-5 flex justify-end"> + <Dialog.Close className={buttonClasses({ variant: "outline", color: "secondary", size: "small" })}> + Done + </Dialog.Close> + </div> + </Dialog.Popup> + </Dialog.Portal> + </Dialog.Root> + ); +} diff --git a/src/settings/CalendarSetsSection.tsx b/src/settings/CalendarSetsSection.tsx new file mode 100644 index 0000000..3338980 --- /dev/null +++ b/src/settings/CalendarSetsSection.tsx @@ -0,0 +1,169 @@ +import { useEffect, useState } from "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"; +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"; +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 +// 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<string | null>(null); + const [name, setName] = useState(""); + const [editingId, setEditingId] = useState<number | null>(null); + const [editingName, setEditingName] = useState(""); + const [deleteTarget, setDeleteTarget] = useState<CalendarSet | null>(null); + const [membersTarget, setMembersTarget] = useState<CalendarSet | null>(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 ( + <section> + <h2 className="text-heading font-medium text-ink">Calendar sets</h2> + <p className="mt-1 text-body text-ink-muted"> + Save named selections of this workspace's calendars, private to you, so you can switch your view in one + click. + </p> + + {loadError && <p className="mt-2 text-label-sm text-danger">{loadError}</p>} + + <form onSubmit={handleCreate} className="mt-4 flex items-end gap-2"> + <Input + label="New set" + placeholder="Work" + value={name} + onChange={(domEvent) => setName(domEvent.target.value)} + className="w-72" + /> + <Button type="submit" disabled={!name.trim()} loading={isCreating} leadingIcon={<Plus className="size-4" />}> + Create + </Button> + </form> + + {createError && <p className="mt-2 text-label-sm text-danger">{createError}</p>} + + <ul className="mt-4 flex flex-col gap-2"> + {calendarSets.map((calendarSet) => { + const isEditing = editingId === calendarSet.id; + return ( + <li key={calendarSet.id} className="rounded-md border border-border px-3 py-2"> + <div className="flex items-center justify-between gap-2"> + {isEditing ? ( + <Input + autoFocus + value={editingName} + onChange={(domEvent) => 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}`} + /> + ) : ( + <p className="text-body text-ink">{calendarSet.name}</p> + )} + + <div className="flex items-center gap-1"> + {isEditing ? ( + <> + <IconButton + onClick={() => handleRename(calendarSet)} + disabled={isRenaming} + aria-label={`Save ${calendarSet.name}`} + > + <Check className="size-4" /> + </IconButton> + <IconButton onClick={() => setEditingId(null)} disabled={isRenaming} aria-label="Cancel rename"> + <X className="size-4" /> + </IconButton> + </> + ) : ( + <> + <IconButton + onClick={() => setMembersTarget(calendarSet)} + aria-label={`Manage ${calendarSet.name} calendars`} + > + <CalendarIcon className="size-4" /> + </IconButton> + <IconButton onClick={() => startEditing(calendarSet)} aria-label={`Rename ${calendarSet.name}`}> + <Pencil className="size-4" /> + </IconButton> + <IconButton onClick={() => setDeleteTarget(calendarSet)} aria-label={`Delete ${calendarSet.name}`}> + <Trash2 className="size-4" /> + </IconButton> + </> + )} + </div> + </div> + {isEditing && renameError && <p className="mt-2 text-label-sm text-danger">{renameError}</p>} + </li> + ); + })} + + {calendarSets.length === 0 && !loadError && ( + <p className="text-label-sm text-ink-muted">No calendar sets yet.</p> + )} + </ul> + + {deleteTarget && <DeleteCalendarSetDialog calendarSet={deleteTarget} onClose={() => setDeleteTarget(null)} />} + {membersTarget && ( + <CalendarSetMembershipDialog calendarSet={membersTarget} onClose={() => setMembersTarget(null)} /> + )} + </section> + ); +} diff --git a/src/settings/ConnectionsSection.test.tsx b/src/settings/ConnectionsSection.test.tsx index b13b1a1..e6924fb 100644 --- a/src/settings/ConnectionsSection.test.tsx +++ b/src/settings/ConnectionsSection.test.tsx @@ -42,6 +42,7 @@ const user = { timeFormat: "24h" as const, workingHoursStart: null, workingHoursEnd: null, + handle: null, }; const connectionA = { diff --git a/src/settings/DeleteAvailabilityScheduleDialog.tsx b/src/settings/DeleteAvailabilityScheduleDialog.tsx new file mode 100644 index 0000000..dca9599 --- /dev/null +++ b/src/settings/DeleteAvailabilityScheduleDialog.tsx @@ -0,0 +1,63 @@ +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 { useAvailabilitySchedulesStore } from "../lib/availabilitySchedulesStore"; +import type { AvailabilitySchedule } from "../lib/availabilitySchedulesApi"; + +interface DeleteAvailabilityScheduleDialogProps { + schedule: AvailabilitySchedule; + onClose: () => void; +} + +// Deletes an Availability Schedule outright (#320, ADR-0085). Destroys no +// Booking Link and no Event on its own — nothing in this codebase +// references a Schedule yet (#322 is the first thing that will). +export function DeleteAvailabilityScheduleDialog({ schedule, onClose }: DeleteAvailabilityScheduleDialogProps) { + const deleteSchedule = useAvailabilitySchedulesStore((state) => state.deleteSchedule); + const { isSubmitting, error, run } = useAsyncAction(); + + async function handleConfirm() { + await run(async () => { + await deleteSchedule(schedule.id); + onClose(); + }); + } + + return ( + <Dialog.Root + open + onOpenChange={(open) => { + if (!open && !isSubmitting) onClose(); + }} + > + <Dialog.Portal> + <Dialog.Backdrop className="fixed inset-0 z-40 bg-ink/20" /> + <Dialog.Popup className="fixed top-1/2 left-1/2 z-50 w-80 -translate-x-1/2 -translate-y-1/2 rounded-shell-lg bg-surface p-5 shadow-elevation-3"> + <Dialog.Title className="text-heading font-medium text-ink">Delete {schedule.name}?</Dialog.Title> + <Dialog.Description className="mt-2 text-body text-ink-muted"> + This removes the schedule. This cannot be undone. + </Dialog.Description> + + {error && ( + <p className="mt-2 text-label-sm text-danger" role="alert"> + {error} + </p> + )} + + <div className="mt-5 flex justify-end gap-2"> + <Dialog.Close + className={buttonClasses({ variant: "outline", color: "secondary", size: "small" })} + disabled={isSubmitting} + > + Cancel + </Dialog.Close> + <Button color="danger" size="small" loading={isSubmitting} onClick={handleConfirm}> + Delete schedule + </Button> + </div> + </Dialog.Popup> + </Dialog.Portal> + </Dialog.Root> + ); +} 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 ( + <Dialog.Root + open + onOpenChange={(open) => { + if (!open && !isSubmitting) onClose(); + }} + > + <Dialog.Portal> + <Dialog.Backdrop className="fixed inset-0 z-40 bg-ink/20" /> + <Dialog.Popup className="fixed top-1/2 left-1/2 z-50 w-80 -translate-x-1/2 -translate-y-1/2 rounded-shell-lg bg-surface p-5 shadow-elevation-3"> + <Dialog.Title className="text-heading font-medium text-ink">Delete {calendarSet.name}?</Dialog.Title> + <Dialog.Description className="mt-2 text-body text-ink-muted"> + This removes the calendar set. No calendar and no event is deleted, and this cannot be undone. + </Dialog.Description> + + {error && ( + <p className="mt-2 text-label-sm text-danger" role="alert"> + {error} + </p> + )} + + <div className="mt-5 flex justify-end gap-2"> + <Dialog.Close + className={buttonClasses({ variant: "outline", color: "secondary", size: "small" })} + disabled={isSubmitting} + > + Cancel + </Dialog.Close> + <Button color="danger" size="small" loading={isSubmitting} onClick={handleConfirm}> + Delete set + </Button> + </div> + </Dialog.Popup> + </Dialog.Portal> + </Dialog.Root> + ); +} 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<typeof import("../lib/connectionsApi")>("../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( + <DisconnectConnectionModal + connectionId={1} + accountEmail="someone@gmail.com" + onClose={vi.fn()} + />, + ); + + 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, diff --git a/src/settings/settingsSections.tsx b/src/settings/settingsSections.tsx index ca8a4ff..6fd5448 100644 --- a/src/settings/settingsSections.tsx +++ b/src/settings/settingsSections.tsx @@ -4,7 +4,9 @@ import { Bell, Boxes, Cable, + Clock, KeyRound, + Layers, SlidersHorizontal, UserRound, UsersRound, @@ -18,6 +20,8 @@ import { ReminderDeliverySection } from "./ReminderDeliverySection"; import { ImportExportSection } from "./ImportExportSection"; import { MembersSection } from "./MembersSection"; import { GroupsSection } from "./GroupsSection"; +import { CalendarSetsSection } from "./CalendarSetsSection"; +import { AvailabilitySchedulesSection } from "./AvailabilitySchedulesSection"; export type SettingsGroup = "personal" | "workspace"; @@ -47,11 +51,25 @@ 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: <PreferencesSection /> }, { path: "account", label: "Account", group: "personal", icon: UserRound, element: <AccountSection /> }, { path: "connections", label: "Connections", group: "personal", icon: Cable, element: <ConnectionsSection /> }, + { path: "calendar-sets", label: "Calendar sets", group: "personal", icon: Layers, element: <CalendarSetsSection /> }, + { + path: "availability-schedules", + label: "Availability schedules", + group: "personal", + icon: Clock, + element: <AvailabilitySchedulesSection />, + }, { path: "members", label: "Members", group: "workspace", icon: UsersRound, element: <MembersSection /> }, { path: "groups", label: "Groups", group: "workspace", icon: Boxes, element: <GroupsSection /> }, { path: "app-passwords", label: "App passwords", group: "personal", icon: KeyRound, element: <AppPasswordsSection /> },