From 7828c1e6edc522d3b9905079a220ecd3a2eae255 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Thu, 20 Aug 2026 06:50:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(gcal):=20Google=20Calendar=20kan=20l=C3=A6?= =?UTF-8?q?ses,=20og=20kalenderen=20f=C3=A5r=20sine=20egne=20tabeller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Klienten henter kalenderlisten og begivenhederne i et vindue, og laver Googles to former om til den ene, et gitter kan spørge om: dage i begge ender, begge med. Googles heldags-slutdato er eksklusiv, og et møde, der slutter ved midnat, slutter aftenen før — begge dele rettes ét sted frem for hos hver læser. Egne tabeller frem for `mailboxes`. De to ligner hinanden på afstand og er uenige om hver eneste kolonne bagefter: `trigger_kind`, `seen`, `last_uid` og `project_id` svarer alle på "hvad laver en besked om til en opgave", og intet her bliver til en opgave. Én konto rummer desuden mange kalendere, og en en-til-mange er der ikke plads til. Det afgørende er mindre og mere praktisk: `mailboxes.kind` har en CHECK, og SQLite kan ikke ændre en CHECK — en tredje værdi koster en tabelombygning med fremmednøgler slået fra. Derfor er der ingen CHECK på `provider` her: CalDAV skal koste en række, ikke en ombygning. --- internal/gcal/gcal.go | 328 ++++++++++++++++ internal/gcal/gcal_test.go | 255 +++++++++++++ internal/store/calendars.go | 380 +++++++++++++++++++ internal/store/calendars_test.go | 358 +++++++++++++++++ internal/store/migrations/0023_calendars.sql | 111 ++++++ 5 files changed, 1432 insertions(+) create mode 100644 internal/gcal/gcal.go create mode 100644 internal/gcal/gcal_test.go create mode 100644 internal/store/calendars.go create mode 100644 internal/store/calendars_test.go create mode 100644 internal/store/migrations/0023_calendars.sql diff --git a/internal/gcal/gcal.go b/internal/gcal/gcal.go new file mode 100644 index 0000000..da71332 --- /dev/null +++ b/internal/gcal/gcal.go @@ -0,0 +1,328 @@ +// Package gcal reads a Google Calendar. It never writes one. +// +// The scope is calendar.readonly, and that is the whole of the decision: verdande +// already is a CalDAV server, so a Google event shown here is a second calendar +// laid over the first, not a copy Google and verdande now both have to agree +// about. Writing needs `calendar.events`, and it needs an answer to what happens +// when both sides moved the same meeting — which is a synchronisation model, not a +// scope. Read first; write later and to one calendar at a time. +// +// What the API gives back and what this returns are deliberately different shapes. +// Google answers with RFC 3339 instants for a timed event and bare dates for an +// all-day one, and with an *exclusive* end date for the second. A calendar grid +// asks one question — "which cells does this event cover" — so that is what an +// Event answers, in days, worked out once here rather than in every caller. +package gcal + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/kristianwind/verdande/internal/google" +) + +// Scope is read-only, and only the calendar. +// +// Google also offers calendar.calendarlist.readonly and calendar.events.readonly, +// which together are strictly less than this — they leave out access rules and +// account settings, which verdande has no use for. They are the tighter pair and +// worth moving to; they are also newer, and this one has been documented and +// accepted unchanged for a decade. A consent screen that fails with `invalid_scope` +// is not a place to be adventurous on somebody else's Google account. +const Scope = "https://www.googleapis.com/auth/calendar.readonly" + +const defaultAPIBase = "https://www.googleapis.com/calendar/v3" + +// ErrUnauthorized means the tokens are no longer good — access was revoked, or the +// password changed. Re-exported so a caller need not know the flow lives elsewhere. +var ErrUnauthorized = google.ErrUnauthorized + +type Client struct { + accessToken string + // base is where the Calendar API is. A field rather than a constant so a test + // can point it at a server it controls; empty is Google. There is no other way + // to exercise a calendar that is slow, empty, or refuses. + base string + http *http.Client +} + +func NewClient(accessToken string) *Client { + return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}} +} + +// At points the client at another server. Tests only; the zero value is Google. +func (c *Client) At(base string) *Client { + c.base = base + return c +} + +func (c *Client) api() string { + if c.base != "" { + return c.base + } + return defaultAPIBase +} + +// Calendar is one entry in the person's calendar list. +type Calendar struct { + ID string + // Name is the summary Google holds. A calendar somebody renamed for themselves + // carries summaryOverride instead, and that is the name they will look for. + Name string + // Colour is Google's own hex value for the calendar, which is what makes two + // calendars in one grid tellable apart. Their own colour rather than one picked + // here: it is the colour they already know the calendar by. + Colour string + TimeZone string + Primary bool + // Owner means the connected account can write to it. Nothing writes today — + // it is stored so the day something does, the interface knows which calendars + // could even be offered. + Owner bool +} + +// Calendars lists what the connected account can see. +func (c *Client) Calendars(ctx context.Context) ([]Calendar, error) { + var out []Calendar + page := "" + // Bounded rather than "until Google stops": a page token that never empties is + // a loop that never ends, and the difference between a bug at Google and a + // hung sync should not be this server hanging. + for range 10 { + v := url.Values{} + v.Set("minAccessRole", "reader") + v.Set("maxResults", "250") + if page != "" { + v.Set("pageToken", page) + } + + var parsed struct { + Items []struct { + ID string `json:"id"` + Summary string `json:"summary"` + SummaryOverride string `json:"summaryOverride"` + BackgroundColor string `json:"backgroundColor"` + TimeZone string `json:"timeZone"` + Primary bool `json:"primary"` + AccessRole string `json:"accessRole"` + Deleted bool `json:"deleted"` + } `json:"items"` + NextPageToken string `json:"nextPageToken"` + } + if err := c.get(ctx, c.api()+"/users/me/calendarList?"+v.Encode(), &parsed); err != nil { + return nil, err + } + + for _, item := range parsed.Items { + if item.Deleted { + continue + } + name := item.SummaryOverride + if name == "" { + name = item.Summary + } + out = append(out, Calendar{ + ID: item.ID, Name: name, Colour: item.BackgroundColor, + TimeZone: item.TimeZone, Primary: item.Primary, + Owner: item.AccessRole == "owner" || item.AccessRole == "writer", + }) + } + if parsed.NextPageToken == "" { + break + } + page = parsed.NextPageToken + } + return out, nil +} + +// Event is one occurrence, in the shape a calendar grid asks about. +type Event struct { + ID string + Summary string + // StartsAt and EndsAt are RFC 3339 for a timed event and empty for an all-day + // one — an all-day event has no instant, only a day, and inventing midnight for + // it is how an event moves a day every time a server changes time zone. + StartsAt string + EndsAt string + // StartDay and EndDay are the first and last day the event covers, inclusive at + // both ends. Google's all-day end date is exclusive; that is corrected here, + // once, rather than in every reader. + StartDay string + EndDay string + AllDay bool + Location string + // URL opens the event in Google's own interface. Same reasoning as the Gmail + // link on a task made from a mail: what verdande shows is a pointer to the + // event, not a second copy of it that somebody could try to edit. + URL string +} + +// Events lists what falls inside a window, one calendar at a time. +// +// `singleEvents` expands a recurrence into its occurrences, which is the only form +// a grid can draw: an RRULE is an instruction, and a Tuesday cell needs the Tuesday +// it produced. It also means verdande does not have to grow a second recurrence +// engine that agrees with Google's about Danish summer time. +// +// from and to are dates, inclusive. The window is the caller's business; this +// turns it into the half-open interval Google wants. +func (c *Client) Events(ctx context.Context, calendarID, from, to string) ([]Event, error) { + start, err := time.Parse(dateLayout, from) + if err != nil { + return nil, fmt.Errorf("gcal: from: %w", err) + } + end, err := time.Parse(dateLayout, to) + if err != nil { + return nil, fmt.Errorf("gcal: to: %w", err) + } + + var out []Event + page := "" + for range 20 { + v := url.Values{} + v.Set("singleEvents", "true") + v.Set("orderBy", "startTime") + v.Set("maxResults", "2500") + // UTC, and a day of slack at each end. An event at 23:30 on the last day in + // Copenhagen is 21:30 UTC that day, but one at 00:30 on the first day is + // 22:30 UTC the day *before* — so a window cut exactly at the requested + // dates loses the events at both edges. The extra day is thrown away again + // by the day fields below, which are computed from the event's own offset. + v.Set("timeMin", start.AddDate(0, 0, -1).Format(time.RFC3339)) + v.Set("timeMax", end.AddDate(0, 0, 2).Format(time.RFC3339)) + if page != "" { + v.Set("pageToken", page) + } + + var parsed struct { + Items []rawEvent `json:"items"` + NextPageToken string `json:"nextPageToken"` + } + url := c.api() + "/calendars/" + url.PathEscape(calendarID) + "/events?" + v.Encode() + if err := c.get(ctx, url, &parsed); err != nil { + return nil, err + } + + for _, item := range parsed.Items { + // A cancelled occurrence of a recurring event still comes back, so that + // a client keeping its own copy knows to drop it. verdande replaces the + // window wholesale, so it only has to not add it. + if item.Status == "cancelled" { + continue + } + event, ok := item.event() + if !ok { + continue + } + if event.EndDay < from || event.StartDay > to { + continue // the slack above, thrown away + } + out = append(out, event) + } + if parsed.NextPageToken == "" { + break + } + page = parsed.NextPageToken + } + return out, nil +} + +const dateLayout = "2006-01-02" + +type rawEvent struct { + ID string `json:"id"` + Status string `json:"status"` + Summary string `json:"summary"` + Location string `json:"location"` + HTMLLink string `json:"htmlLink"` + Start when `json:"start"` + End when `json:"end"` +} + +type when struct { + Date string `json:"date"` + DateTime string `json:"dateTime"` +} + +// event turns Google's two shapes into the one a grid can read. +// +// The second return is false for an event with neither a date nor a dateTime — +// which Google does return, for an event somebody is still being asked about. It +// belongs on no day, so there is no cell to draw it in. +func (r rawEvent) event() (Event, bool) { + e := Event{ + ID: r.ID, Summary: strings.TrimSpace(r.Summary), + Location: r.Location, URL: r.HTMLLink, + } + if e.Summary == "" { + // Google's own interface says this for an event with no title, and a blank + // chip in a grid reads as a rendering fault rather than as an empty title. + e.Summary = "(uden titel)" + } + + switch { + case r.Start.Date != "": + e.AllDay = true + e.StartDay = r.Start.Date + // Google's all-day end is the day *after* the last one, so a one-day event + // ends tomorrow. Subtracting a day is what makes "covers this cell" a + // comparison rather than a special case at every call site. + e.EndDay = r.Start.Date + if day, err := time.Parse(dateLayout, r.End.Date); err == nil { + if last := day.AddDate(0, 0, -1); !last.Before(mustDay(r.Start.Date)) { + e.EndDay = last.Format(dateLayout) + } + } + case r.Start.DateTime != "": + e.StartsAt = r.Start.DateTime + e.EndsAt = r.End.DateTime + // The day is the date part of the timestamp Google returned, which carries + // the event's own offset. Parsing it into a time.Time and formatting it + // back would ask *this process* what day it is — and a container running in + // UTC would then move a 23:30 meeting in Copenhagen to the day before. + e.StartDay = dayOf(r.Start.DateTime) + e.EndDay = dayOf(r.End.DateTime) + if e.EndDay == "" { + e.EndDay = e.StartDay + } + // An event ending at midnight ends the evening before, as far as a grid is + // concerned. Without this, every meeting that runs to 24:00 puts a chip in + // the following day as well. + if e.EndDay > e.StartDay && strings.Contains(r.End.DateTime, "T00:00:00") { + if day, err := time.Parse(dateLayout, e.EndDay); err == nil { + e.EndDay = day.AddDate(0, 0, -1).Format(dateLayout) + } + } + default: + return Event{}, false + } + if e.EndDay < e.StartDay { + e.EndDay = e.StartDay + } + return e, true +} + +// dayOf takes the date out of an RFC 3339 timestamp without interpreting it. +func dayOf(stamp string) string { + if len(stamp) < len(dateLayout) { + return "" + } + day := stamp[:len(dateLayout)] + if _, err := time.Parse(dateLayout, day); err != nil { + return "" + } + return day +} + +func mustDay(s string) time.Time { + day, _ := time.Parse(dateLayout, s) + return day +} + +func (c *Client) get(ctx context.Context, url string, out any) error { + return google.Get(ctx, c.http, c.accessToken, url, out) +} diff --git a/internal/gcal/gcal_test.go b/internal/gcal/gcal_test.go new file mode 100644 index 0000000..d47d20f --- /dev/null +++ b/internal/gcal/gcal_test.go @@ -0,0 +1,255 @@ +package gcal + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// fakeGoogle answers the two calls this package makes, out of whatever the test +// hands it. There is no other way to exercise this: the real Calendar needs an +// account, a registered client and somebody's consent, and none of the three can be +// had inside a test — so the shapes Google returns are written down here and the +// parsing is checked against them. +func fakeGoogle(t *testing.T, calendarList any, events map[string]any) *Client { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasPrefix(r.URL.Path, "/users/me/calendarList"): + _ = json.NewEncoder(w).Encode(calendarList) + case strings.HasPrefix(r.URL.Path, "/calendars/"): + id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/calendars/"), "/events") + body, ok := events[id] + if !ok { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(body) + default: + http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + return NewClient("token").At(srv.URL) +} + +func TestCalendarsPrefersTheNameThePersonGaveIt(t *testing.T) { + client := fakeGoogle(t, map[string]any{ + "items": []map[string]any{ + {"id": "primary", "summary": "kw@nolimit.dk", "backgroundColor": "#3f51b5", + "timeZone": "Europe/Copenhagen", "primary": true, "accessRole": "owner"}, + {"id": "fam", "summary": "Family", "summaryOverride": "Familien", + "backgroundColor": "#0b8043", "accessRole": "reader"}, + // A calendar removed from the list still arrives, flagged. + {"id": "gone", "summary": "Væk", "deleted": true, "accessRole": "reader"}, + }, + }, nil) + + got, err := client.Calendars(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("got %d calendars, want 2 — a deleted one is not one you can show", len(got)) + } + if got[1].Name != "Familien" { + t.Errorf("name = %q; summaryOverride is the name the person will look for", got[1].Name) + } + if !got[0].Primary || !got[0].Owner { + t.Errorf("the primary calendar came back as %+v", got[0]) + } + if got[1].Owner { + t.Error("a reader was reported as an owner; nothing may offer to write to it") + } + if got[0].Colour != "#3f51b5" { + t.Errorf("colour = %q; two calendars in one grid are told apart by it", got[0].Colour) + } +} + +// The three shapes an event arrives in, and the one shape a grid can read. +func TestEventsBecomeDaysAGridCanAskAbout(t *testing.T) { + client := fakeGoogle(t, nil, map[string]any{ + "primary": map[string]any{ + "items": []map[string]any{ + { + "id": "timed", "summary": "Bestyrelsesmøde", "htmlLink": "https://cal/1", + "start": map[string]string{"dateTime": "2026-08-20T14:00:00+02:00"}, + "end": map[string]string{"dateTime": "2026-08-20T15:30:00+02:00"}, + }, + { + // All-day, one day. Google's end is the day after. + "id": "allday", "summary": "Fridag", + "start": map[string]string{"date": "2026-08-21"}, + "end": map[string]string{"date": "2026-08-22"}, + }, + { + // All-day, three days. + "id": "trip", "summary": "Aarhus", + "start": map[string]string{"date": "2026-08-24"}, + "end": map[string]string{"date": "2026-08-27"}, + }, + { + // Runs to midnight, which is the evening before as far as a + // grid is concerned. + "id": "late", "summary": "Nattevagt", + "start": map[string]string{"dateTime": "2026-08-22T20:00:00+02:00"}, + "end": map[string]string{"dateTime": "2026-08-23T00:00:00+02:00"}, + }, + { + // A cancelled occurrence of a recurring event. + "id": "off", "status": "cancelled", "summary": "Aflyst", + "start": map[string]string{"date": "2026-08-25"}, + "end": map[string]string{"date": "2026-08-26"}, + }, + { + // Neither a date nor a dateTime: belongs on no day at all. + "id": "nowhere", "summary": "Uden tid", + }, + }, + }, + }) + + got, err := client.Events(t.Context(), "primary", "2026-08-01", "2026-08-31") + if err != nil { + t.Fatal(err) + } + + byID := map[string]Event{} + for _, e := range got { + byID[e.ID] = e + } + if len(got) != 4 { + t.Fatalf("got %d events, want 4: %+v", len(got), got) + } + if _, ok := byID["off"]; ok { + t.Error("a cancelled occurrence was kept; it is an event that is not happening") + } + if _, ok := byID["nowhere"]; ok { + t.Error("an event with no time was kept; there is no cell to draw it in") + } + + timed := byID["timed"] + if timed.AllDay || timed.StartDay != "2026-08-20" || timed.EndDay != "2026-08-20" { + t.Errorf("timed = %+v", timed) + } + if timed.StartsAt != "2026-08-20T14:00:00+02:00" { + t.Errorf("the instant was rewritten: %q", timed.StartsAt) + } + + allday := byID["allday"] + if !allday.AllDay || allday.StartDay != "2026-08-21" || allday.EndDay != "2026-08-21" { + t.Errorf("allday = %+v; Google's end date is exclusive and has to be corrected here", + allday) + } + if allday.StartsAt != "" { + t.Errorf("an all-day event was given an instant: %q — it has a day, not a moment", + allday.StartsAt) + } + + if trip := byID["trip"]; trip.StartDay != "2026-08-24" || trip.EndDay != "2026-08-26" { + t.Errorf("trip = %+v, want 24th to 26th inclusive", trip) + } + if late := byID["late"]; late.EndDay != "2026-08-22" { + t.Errorf("late ends on %q; an event running to midnight ends the evening before, "+ + "or every one of them puts a chip in the following day", late.EndDay) + } +} + +// An event with no title has to say so. A blank chip in a grid reads as a +// rendering fault rather than as an empty title. +func TestAnEventWithNoTitleStillSaysSomething(t *testing.T) { + client := fakeGoogle(t, nil, map[string]any{ + "c": map[string]any{"items": []map[string]any{{ + "id": "x", + "start": map[string]string{"date": "2026-08-20"}, + "end": map[string]string{"date": "2026-08-21"}, + }}}, + }) + got, err := client.Events(t.Context(), "c", "2026-08-01", "2026-08-31") + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Summary == "" { + t.Fatalf("got %+v", got) + } +} + +// The window is asked for with a day of slack at each end, because an event late +// in the evening in Copenhagen is the next day in UTC. The slack must not leak +// out: an event on the day before the window is not in the window. +func TestTheSlackAtTheEdgesIsThrownAwayAgain(t *testing.T) { + client := fakeGoogle(t, nil, map[string]any{ + "c": map[string]any{"items": []map[string]any{ + {"id": "before", "summary": "For tidligt", + "start": map[string]string{"date": "2026-08-09"}, + "end": map[string]string{"date": "2026-08-10"}}, + {"id": "inside", "summary": "Med", + "start": map[string]string{"date": "2026-08-10"}, + "end": map[string]string{"date": "2026-08-11"}}, + {"id": "after", "summary": "For sent", + "start": map[string]string{"date": "2026-08-21"}, + "end": map[string]string{"date": "2026-08-22"}}, + }}, + }) + got, err := client.Events(t.Context(), "c", "2026-08-10", "2026-08-20") + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].ID != "inside" { + t.Fatalf("got %+v, want only the one inside the window", got) + } +} + +// A page token has to be followed, or a busy calendar silently shows its first +// page and nothing else — which is a calendar that lies about a day being clear. +func TestEveryPageIsRead(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + body := map[string]any{ + "items": []map[string]any{{"id": "a", "summary": "Første", + "start": map[string]string{"date": "2026-08-11"}, + "end": map[string]string{"date": "2026-08-12"}}}, + "nextPageToken": "more", + } + if r.URL.Query().Get("pageToken") == "more" { + body = map[string]any{"items": []map[string]any{{"id": "b", "summary": "Anden", + "start": map[string]string{"date": "2026-08-12"}, + "end": map[string]string{"date": "2026-08-13"}}}} + } + _ = json.NewEncoder(w).Encode(body) + })) + defer srv.Close() + + got, err := NewClient("t").At(srv.URL).Events(t.Context(), "c", "2026-08-01", "2026-08-31") + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("got %d events, want both pages: %+v", len(got), got) + } +} + +// A 401 is not a failure to retry; it is a connection that has ended. Telling the +// two apart is what stops a revoked account being polled every ten minutes for ever. +func TestARefusedTokenIsItsOwnError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"invalid_credentials"}`, http.StatusUnauthorized) + })) + defer srv.Close() + + _, err := NewClient("spent").At(srv.URL).Calendars(t.Context()) + if err != ErrUnauthorized { + t.Fatalf("err = %v, want ErrUnauthorized", err) + } +} + +// Read-only, and only the calendar. The consent screen is what somebody agrees to, +// and a scope that quietly grew is a promise quietly broken. +func TestTheScopeIsReadOnly(t *testing.T) { + if !strings.HasSuffix(Scope, "calendar.readonly") { + t.Errorf("scope = %q; nothing here writes a calendar", Scope) + } +} diff --git a/internal/store/calendars.go b/internal/store/calendars.go new file mode 100644 index 0000000..4e1ed00 --- /dev/null +++ b/internal/store/calendars.go @@ -0,0 +1,380 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +// CalendarAccount is one connected account belonging to one person. +type CalendarAccount struct { + ID string `json:"id"` + UserID string `json:"-"` + Provider string `json:"provider"` + Account string `json:"account,omitempty"` + + RefreshToken string `json:"-"` // never leaves the server + AccessToken string `json:"-"` + ExpiresAt time.Time `json:"-"` + + LastSyncAt time.Time `json:"last_sync_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// Calendar is one calendar inside an account, shown or not. +type Calendar struct { + ID string `json:"id"` + AccountID string `json:"-"` + RemoteID string `json:"remote_id"` + Name string `json:"name"` + Colour string `json:"colour,omitempty"` + TimeZone string `json:"time_zone,omitempty"` + Primary bool `json:"primary"` + // Writable says the connected account could write to it. Nothing does — the + // scope is read-only — and it is stored so the day something can, the interface + // knows which calendars could even be offered. + Writable bool `json:"writable"` + Shown bool `json:"shown"` + SortOrder int `json:"-"` +} + +// CalendarEvent is one occurrence, in the shape a grid asks about: days at both +// ends, inclusive. +type CalendarEvent struct { + ID string `json:"id"` + CalendarID string `json:"calendar_id"` + RemoteID string `json:"-"` + Summary string `json:"summary"` + StartsAt string `json:"starts_at,omitempty"` + EndsAt string `json:"ends_at,omitempty"` + StartDay string `json:"start_day"` + EndDay string `json:"end_day"` + AllDay bool `json:"all_day"` + Location string `json:"location,omitempty"` + URL string `json:"url,omitempty"` + + // CalendarName and Colour ride along with the event rather than being looked up + // per row by the caller. Same reasoning as the assignee names on a task list: + // the chip has to be drawn in the calendar's own colour, and a second request + // per event to learn it is a request per event. + CalendarName string `json:"calendar_name,omitempty"` + Colour string `json:"colour,omitempty"` +} + +const calendarAccountColumns = `id, user_id, provider, account, refresh_token, + access_token, expires_at, last_sync_at, created_at` + +// CalendarAccountFor returns a person's connection to one provider, or nil. +func (db *DB) CalendarAccountFor(ctx context.Context, userID, provider string) (*CalendarAccount, error) { + row := db.QueryRowContext(ctx, `SELECT `+calendarAccountColumns+` + FROM calendar_accounts WHERE user_id = ? AND provider = ?`, userID, provider) + a, err := db.scanCalendarAccount(row) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &a, nil +} + +// SaveCalendarAccount writes one, sealing the two columns that must not travel +// with a backup. Called by hand rather than by the settings tables' own seal, +// which does not reach this table. +func (db *DB) SaveCalendarAccount(ctx context.Context, a *CalendarAccount) error { + if a.ID == "" { + a.ID = NewID() + } + if a.Provider == "" { + a.Provider = "google" + } + if a.CreatedAt.IsZero() { + a.CreatedAt = time.Now() + } + + refresh, err := db.sealValue(a.RefreshToken) + if err != nil { + return fmt.Errorf("seal refresh_token: %w", err) + } + access, err := db.sealValue(a.AccessToken) + if err != nil { + return fmt.Errorf("seal access_token: %w", err) + } + + // The conflict is on the pair, not on the id: a reconnect builds a fresh + // struct with no id in it, and matching on the id would insert a second row + // that the unique index then refuses — which reads to the person as "connecting + // failed" when what happened is that it already had. + _, err = db.ExecContext(ctx, ` + INSERT INTO calendar_accounts (`+calendarAccountColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, provider) DO UPDATE SET + account = excluded.account, + refresh_token = excluded.refresh_token, + access_token = excluded.access_token, + expires_at = excluded.expires_at, + last_sync_at = excluded.last_sync_at`, + a.ID, a.UserID, a.Provider, a.Account, refresh, access, + unixOrZero(a.ExpiresAt), unixOrZero(a.LastSyncAt), a.CreatedAt.Unix()) + if err != nil { + return err + } + + // The row that is actually there wins. On a reconnect the insert above updated + // an existing row, so the id in hand is one nothing points at — and the + // calendars are hung off the id, so writing them against it would attach them + // to nothing. + return db.QueryRowContext(ctx, + `SELECT id FROM calendar_accounts WHERE user_id = ? AND provider = ?`, + a.UserID, a.Provider).Scan(&a.ID) +} + +// MarkCalendarSynced records how far a run got. Written separately from the rest +// so a sync never rewrites the credentials it is holding in memory. +func (db *DB) MarkCalendarSynced(ctx context.Context, accountID string, at time.Time) error { + _, err := db.ExecContext(ctx, + `UPDATE calendar_accounts SET last_sync_at = ? WHERE id = ?`, at.Unix(), accountID) + return err +} + +// DeleteCalendarAccount disconnects one. The calendars and their events go with it +// — unlike the tasks a mailbox made, none of this is the person's own work; it is +// a copy of somebody else's calendar and it has no meaning without the account. +func (db *DB) DeleteCalendarAccount(ctx context.Context, userID, id string) error { + _, err := db.ExecContext(ctx, + `DELETE FROM calendar_accounts WHERE id = ? AND user_id = ?`, id, userID) + return err +} + +// UsersWithCalendars returns everybody the sweep has to visit. +func (db *DB) UsersWithCalendars(ctx context.Context) ([]string, error) { + rows, err := db.QueryContext(ctx, `SELECT DISTINCT user_id FROM calendar_accounts`) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +// Calendars returns what one account holds, in the order the provider listed them. +func (db *DB) Calendars(ctx context.Context, accountID string) ([]Calendar, error) { + rows, err := db.QueryContext(ctx, ` + SELECT id, account_id, remote_id, name, colour, time_zone, primary_one, + writable, shown, sort_order + FROM calendars WHERE account_id = ? ORDER BY sort_order, name`, accountID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Calendar + for rows.Next() { + var c Calendar + if err := rows.Scan(&c.ID, &c.AccountID, &c.RemoteID, &c.Name, &c.Colour, + &c.TimeZone, &c.Primary, &c.Writable, &c.Shown, &c.SortOrder); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// ReplaceCalendars writes the list the provider just gave, keeping what the person +// chose. +// +// The choice survives because it is not in the list: Google says which calendars +// exist and what they are called, and only verdande knows which of them somebody +// wants to look at. Merged rather than deleted and rewritten, so ticking a calendar +// on does not come undone the next time the sweep runs. +// +// A calendar that has disappeared from the account is removed, and its events go +// with it — a calendar somebody was unshared from should stop being drawn, not +// stay behind as a frozen copy of what it held on the last good day. +func (db *DB) ReplaceCalendars(ctx context.Context, accountID string, list []Calendar) error { + return db.Tx(ctx, func(tx *sql.Tx) error { + keep := map[string]bool{} + for i, c := range list { + keep[c.RemoteID] = true + // The insert cannot carry `shown`: on conflict it would overwrite the + // choice with the default. A new calendar starts hidden — an account + // with a dozen shared calendars in it should not fill somebody's grid + // the moment they connect it. + if _, err := tx.ExecContext(ctx, ` + INSERT INTO calendars + (id, account_id, remote_id, name, colour, time_zone, primary_one, + writable, shown, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (account_id, remote_id) DO UPDATE SET + name = excluded.name, + colour = excluded.colour, + time_zone = excluded.time_zone, + primary_one = excluded.primary_one, + writable = excluded.writable, + sort_order = excluded.sort_order`, + NewID(), accountID, c.RemoteID, c.Name, c.Colour, c.TimeZone, + c.Primary, c.Writable, c.Shown, i); err != nil { + return err + } + } + + rows, err := tx.QueryContext(ctx, + `SELECT id, remote_id FROM calendars WHERE account_id = ?`, accountID) + if err != nil { + return err + } + var gone []string + for rows.Next() { + var id, remote string + if err := rows.Scan(&id, &remote); err != nil { + rows.Close() + return err + } + if !keep[remote] { + gone = append(gone, id) + } + } + // Closed before the next statement runs on this transaction. An open result + // set with a query behind it is the deadlock the pool comment in store.go + // exists to make slow rather than fatal. + rows.Close() + if err := rows.Err(); err != nil { + return err + } + + for _, id := range gone { + if _, err := tx.ExecContext(ctx, `DELETE FROM calendars WHERE id = ?`, id); err != nil { + return err + } + } + return nil + }) +} + +// ShowCalendars sets which of an account's calendars are drawn. +// +// The whole set in one write rather than one flip at a time, for the reason the +// projects' order is written the same way: a person has a handful of calendars, the +// question is "these ones", and a list that can only be built by several requests +// can land half applied. +func (db *DB) ShowCalendars(ctx context.Context, accountID string, shown []string) error { + return db.Tx(ctx, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, + `UPDATE calendars SET shown = 0 WHERE account_id = ?`, accountID); err != nil { + return err + } + for _, id := range shown { + // Scoped in the SQL rather than checked first: an id read in one step + // and written in the next is a window, and the account is the whole of + // the permission here. + if _, err := tx.ExecContext(ctx, + `UPDATE calendars SET shown = 1 WHERE id = ? AND account_id = ?`, + id, accountID); err != nil { + return err + } + } + // Events of a calendar nobody is looking at are dead weight in a table the + // grid queries. They come back with the next sync if it is turned on again. + _, err := tx.ExecContext(ctx, ` + DELETE FROM calendar_events WHERE calendar_id IN + (SELECT id FROM calendars WHERE account_id = ? AND shown = 0)`, accountID) + return err + }) +} + +// ReplaceCalendarEvents swaps one calendar's events inside a window. +// +// Replaced rather than merged, and no sync token. Google offers incremental sync, +// and it would save bytes verdande is not short of while costing the one thing it +// cannot afford: a token that has to be right, forever, for the grid not to be +// quietly wrong. A window that is deleted and rewritten cannot drift — an event +// somebody moved out of the window disappears because it is not in the answer, not +// because verdande remembered to delete it. +// +// Bounded by the window at both ends, so a sync of September does not throw away +// August. +func (db *DB) ReplaceCalendarEvents(ctx context.Context, calendarID, from, to string, events []CalendarEvent) error { + return db.Tx(ctx, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, ` + DELETE FROM calendar_events + WHERE calendar_id = ? AND end_day >= ? AND start_day <= ?`, + calendarID, from, to); err != nil { + return err + } + for _, e := range events { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO calendar_events + (id, calendar_id, remote_id, summary, starts_at, ends_at, + start_day, end_day, all_day, location, url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + NewID(), calendarID, e.RemoteID, e.Summary, e.StartsAt, e.EndsAt, + e.StartDay, e.EndDay, e.AllDay, e.Location, e.URL); err != nil { + return err + } + } + return nil + }) +} + +// CalendarEvents returns everything one person can see between two days, +// inclusive at both ends. +// +// Scoped to the caller in the join rather than checked afterwards: the events of +// somebody else's calendar are somebody else's, and a filter applied after the read +// is a filter that can be forgotten. +func (db *DB) CalendarEvents(ctx context.Context, userID, from, to string) ([]CalendarEvent, error) { + rows, err := db.QueryContext(ctx, ` + SELECT e.id, e.calendar_id, e.remote_id, e.summary, e.starts_at, e.ends_at, + e.start_day, e.end_day, e.all_day, e.location, e.url, c.name, c.colour + FROM calendar_events e + JOIN calendars c ON c.id = e.calendar_id + JOIN calendar_accounts a ON a.id = c.account_id + WHERE a.user_id = ? AND c.shown = 1 AND e.end_day >= ? AND e.start_day <= ? + ORDER BY e.start_day, e.all_day DESC, e.starts_at`, userID, from, to) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []CalendarEvent + for rows.Next() { + var e CalendarEvent + if err := rows.Scan(&e.ID, &e.CalendarID, &e.RemoteID, &e.Summary, &e.StartsAt, + &e.EndsAt, &e.StartDay, &e.EndDay, &e.AllDay, &e.Location, &e.URL, + &e.CalendarName, &e.Colour); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + +func (db *DB) scanCalendarAccount(row scanner) (CalendarAccount, error) { + var a CalendarAccount + var expires, lastSync, created int64 + err := row.Scan(&a.ID, &a.UserID, &a.Provider, &a.Account, &a.RefreshToken, + &a.AccessToken, &expires, &lastSync, &created) + if err != nil { + return a, err + } + for _, field := range []*string{&a.RefreshToken, &a.AccessToken} { + plain, err := db.unsealValue(*field) + if err != nil { + return a, err + } + *field = plain + } + a.ExpiresAt = timeOrZero(expires) + a.LastSyncAt = timeOrZero(lastSync) + a.CreatedAt = time.Unix(created, 0) + return a, nil +} diff --git a/internal/store/calendars_test.go b/internal/store/calendars_test.go new file mode 100644 index 0000000..1f80ec1 --- /dev/null +++ b/internal/store/calendars_test.go @@ -0,0 +1,358 @@ +package store + +import ( + "context" + "strings" + "testing" + "time" +) + +func connectedAccount(t *testing.T, db *DB, userID string) *CalendarAccount { + t.Helper() + a := &CalendarAccount{ + UserID: userID, Provider: "google", Account: "kw@nolimit.dk", + RefreshToken: "1//0-a-real-looking-refresh-token", AccessToken: "ya29.access", + ExpiresAt: time.Now().Add(time.Hour), + } + if err := db.SaveCalendarAccount(context.Background(), a); err != nil { + t.Fatal(err) + } + return a +} + +// The same claim the mailbox tokens have to meet: what is written must not be +// readable in the file a backup copies. +func TestCalendarTokensAreNotStoredInTheClear(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + a := connectedAccount(t, db, userID) + + var refresh, access string + if err := db.QueryRowContext(ctx, + `SELECT refresh_token, access_token FROM calendar_accounts WHERE id = ?`, + a.ID).Scan(&refresh, &access); err != nil { + t.Fatal(err) + } + if strings.Contains(refresh, "real-looking") || strings.Contains(access, "ya29") { + t.Fatalf("a token is in the row as written: %q %q", refresh, access) + } + // The address stays readable, which is the reason for sealing named columns + // rather than the whole row. + var account string + if err := db.QueryRowContext(ctx, + `SELECT account FROM calendar_accounts WHERE id = ?`, a.ID).Scan(&account); err != nil { + t.Fatal(err) + } + if account != "kw@nolimit.dk" { + t.Errorf("the address was sealed too: %q", account) + } + + back, err := db.CalendarAccountFor(ctx, userID, "google") + if err != nil { + t.Fatal(err) + } + if back.RefreshToken != a.RefreshToken || back.AccessToken != a.AccessToken { + t.Errorf("the tokens did not survive the round trip: %+v", back) + } +} + +// Connecting again must replace what was there, not fail. It is the same OAuth +// flow run a second time, and the person's word for it is "connect", not "add". +func TestConnectingAgainReplacesTheAccount(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + first := connectedAccount(t, db, userID) + + second := &CalendarAccount{ + UserID: userID, Provider: "google", Account: "kw@nolimit.dk", + RefreshToken: "1//0-fresh", AccessToken: "ya29.fresh", + } + if err := db.SaveCalendarAccount(ctx, second); err != nil { + t.Fatalf("connecting a second time failed: %v", err) + } + // And the id in hand is the row that is actually there. The calendars hang off + // it, so an id nothing points at would attach them to nothing. + if second.ID != first.ID { + t.Errorf("the second connect came back with id %q, want the existing %q", + second.ID, first.ID) + } + + back, err := db.CalendarAccountFor(ctx, userID, "google") + if err != nil { + t.Fatal(err) + } + if back.RefreshToken != "1//0-fresh" { + t.Errorf("the token came back as %q", back.RefreshToken) + } +} + +// Google says which calendars exist; only verdande knows which ones somebody wants +// to look at. A refresh that came back with the same list must not undo the choice. +func TestRefreshingTheListKeepsWhatWasChosen(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + a := connectedAccount(t, db, userID) + + list := []Calendar{ + {RemoteID: "primary", Name: "kw@nolimit.dk", Colour: "#3f51b5", Primary: true}, + {RemoteID: "fam", Name: "Familien", Colour: "#0b8043"}, + {RemoteID: "old", Name: "Et delt"}, + } + if err := db.ReplaceCalendars(ctx, a.ID, list); err != nil { + t.Fatal(err) + } + + // Nothing is shown to begin with: an account with a dozen shared calendars in + // it should not fill somebody's grid the moment they connect it. + stored, err := db.Calendars(ctx, a.ID) + if err != nil { + t.Fatal(err) + } + if len(stored) != 3 { + t.Fatalf("got %d calendars, want 3", len(stored)) + } + for _, c := range stored { + if c.Shown { + t.Errorf("%q was shown without anybody asking for it", c.Name) + } + } + + var famID string + for _, c := range stored { + if c.RemoteID == "fam" { + famID = c.ID + } + } + if err := db.ShowCalendars(ctx, a.ID, []string{famID}); err != nil { + t.Fatal(err) + } + + // The sweep runs again, and one calendar has been renamed and one has gone. + if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{ + {RemoteID: "primary", Name: "kw@nolimit.dk", Colour: "#3f51b5", Primary: true}, + {RemoteID: "fam", Name: "Familien og hunden", Colour: "#0b8043"}, + }); err != nil { + t.Fatal(err) + } + + stored, err = db.Calendars(ctx, a.ID) + if err != nil { + t.Fatal(err) + } + if len(stored) != 2 { + t.Fatalf("got %d calendars; one that has left the account should stop being drawn", + len(stored)) + } + for _, c := range stored { + switch c.RemoteID { + case "fam": + if !c.Shown { + t.Error("the choice was undone by a refresh of the list") + } + if c.Name != "Familien og hunden" { + t.Errorf("the new name did not arrive: %q", c.Name) + } + if c.ID != famID { + t.Error("the row was rebuilt rather than updated; its id is what was chosen") + } + case "primary": + if c.Shown { + t.Error("a calendar nobody chose became shown") + } + } + } +} + +// A window is replaced, not merged. An event moved out of it has to disappear +// because it is absent from the answer — and the months on either side must not go +// with it. +func TestReplacingAWindowLeavesTheMonthsAroundItAlone(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + a := connectedAccount(t, db, userID) + + if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "primary", Name: "Min"}}); err != nil { + t.Fatal(err) + } + stored, _ := db.Calendars(ctx, a.ID) + cal := stored[0] + if err := db.ShowCalendars(ctx, a.ID, []string{cal.ID}); err != nil { + t.Fatal(err) + } + + july := CalendarEvent{RemoteID: "j", Summary: "Juli", StartDay: "2026-07-15", EndDay: "2026-07-15"} + august := CalendarEvent{RemoteID: "a", Summary: "August", StartDay: "2026-08-15", EndDay: "2026-08-15"} + if err := db.ReplaceCalendarEvents(ctx, cal.ID, "2026-07-01", "2026-07-31", + []CalendarEvent{july}); err != nil { + t.Fatal(err) + } + if err := db.ReplaceCalendarEvents(ctx, cal.ID, "2026-08-01", "2026-08-31", + []CalendarEvent{august}); err != nil { + t.Fatal(err) + } + + got, err := db.CalendarEvents(ctx, userID, "2026-07-01", "2026-08-31") + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("got %d events, want both months: %+v", len(got), got) + } + + // August is synced again and the meeting has been cancelled. July stays. + if err := db.ReplaceCalendarEvents(ctx, cal.ID, "2026-08-01", "2026-08-31", nil); err != nil { + t.Fatal(err) + } + got, err = db.CalendarEvents(ctx, userID, "2026-07-01", "2026-08-31") + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Summary != "Juli" { + t.Fatalf("got %+v; syncing August threw away July", got) + } +} + +// The grid asks "which cells does this cover", so an event that straddles the edge +// of the question has to come back. +func TestAnEventThatStraddlesTheWindowIsFound(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + a := connectedAccount(t, db, userID) + if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "c", Name: "Min", Colour: "#0b8043"}}); err != nil { + t.Fatal(err) + } + stored, _ := db.Calendars(ctx, a.ID) + if err := db.ShowCalendars(ctx, a.ID, []string{stored[0].ID}); err != nil { + t.Fatal(err) + } + if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-09-30", + []CalendarEvent{ + {RemoteID: "trip", Summary: "Aarhus", StartDay: "2026-08-28", EndDay: "2026-09-02", AllDay: true}, + }); err != nil { + t.Fatal(err) + } + + // A week that contains neither end of it. + got, err := db.CalendarEvents(ctx, userID, "2026-08-31", "2026-09-01") + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Fatalf("got %+v; an event covering the whole week was not found", got) + } + // The calendar's own colour rides along, or the chip has to ask for it per row. + if got[0].Colour != "#0b8043" || got[0].CalendarName != "Min" { + t.Errorf("the calendar did not come with the event: %+v", got[0]) + } +} + +// Somebody else's calendar is somebody else's. +func TestOnlyTheOwnersEventsComeBack(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + + other := &User{Email: "andreas@example.dk", Name: "Andreas"} + if err := db.CreateUser(ctx, other, "Indbakke"); err != nil { + t.Fatal(err) + } + theirs := &CalendarAccount{UserID: other.ID, Provider: "google", RefreshToken: "r"} + if err := db.SaveCalendarAccount(ctx, theirs); err != nil { + t.Fatal(err) + } + if err := db.ReplaceCalendars(ctx, theirs.ID, []Calendar{{RemoteID: "c", Name: "Deres"}}); err != nil { + t.Fatal(err) + } + stored, _ := db.Calendars(ctx, theirs.ID) + if err := db.ShowCalendars(ctx, theirs.ID, []string{stored[0].ID}); err != nil { + t.Fatal(err) + } + if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-08-31", + []CalendarEvent{{RemoteID: "x", Summary: "Privat", StartDay: "2026-08-10", EndDay: "2026-08-10"}}); err != nil { + t.Fatal(err) + } + + got, err := db.CalendarEvents(ctx, userID, "2026-08-01", "2026-08-31") + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("somebody else's calendar came back: %+v", got) + } +} + +// A calendar turned off stops being drawn, and stops costing the grid anything. +func TestTurningACalendarOffRemovesItsEvents(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + a := connectedAccount(t, db, userID) + if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "c", Name: "Min"}}); err != nil { + t.Fatal(err) + } + stored, _ := db.Calendars(ctx, a.ID) + if err := db.ShowCalendars(ctx, a.ID, []string{stored[0].ID}); err != nil { + t.Fatal(err) + } + if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-08-31", + []CalendarEvent{{RemoteID: "x", Summary: "Møde", StartDay: "2026-08-10", EndDay: "2026-08-10"}}); err != nil { + t.Fatal(err) + } + + if err := db.ShowCalendars(ctx, a.ID, nil); err != nil { + t.Fatal(err) + } + var count int + if err := db.QueryRowContext(ctx, `SELECT count(*) FROM calendar_events`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Errorf("%d events of a calendar nobody is looking at are still in the table", count) + } +} + +// Disconnecting takes the calendars and the events with it. Unlike the tasks a +// mailbox made, none of this is the person's own work — it is a copy of somebody +// else's calendar and it means nothing without the account. +func TestDisconnectingTakesEverythingWithIt(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + a := connectedAccount(t, db, userID) + if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "c", Name: "Min"}}); err != nil { + t.Fatal(err) + } + stored, _ := db.Calendars(ctx, a.ID) + if err := db.ShowCalendars(ctx, a.ID, []string{stored[0].ID}); err != nil { + t.Fatal(err) + } + if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-08-31", + []CalendarEvent{{RemoteID: "x", Summary: "Møde", StartDay: "2026-08-10", EndDay: "2026-08-10"}}); err != nil { + t.Fatal(err) + } + + if err := db.DeleteCalendarAccount(ctx, userID, a.ID); err != nil { + t.Fatal(err) + } + for _, table := range []string{"calendar_accounts", "calendars", "calendar_events"} { + var count int + if err := db.QueryRowContext(ctx, `SELECT count(*) FROM `+table).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Errorf("%s still holds %d rows after disconnecting", table, count) + } + } +} + +// The sweep finds people by the presence of an account, so it has to keep working +// now that the credential in it is ciphertext. +func TestTheSweepFindsAConnectedCalendar(t *testing.T) { + db, userID := sealedStore(t) + connectedAccount(t, db, userID) + + users, err := db.UsersWithCalendars(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(users) != 1 || users[0] != userID { + t.Fatalf("the sweep found %v", users) + } +} diff --git a/internal/store/migrations/0023_calendars.sql b/internal/store/migrations/0023_calendars.sql new file mode 100644 index 0000000..e143a45 --- /dev/null +++ b/internal/store/migrations/0023_calendars.sql @@ -0,0 +1,111 @@ +-- Calendars read from somewhere else: the account, the calendars in it, and the +-- events they hold. +-- +-- Not in `mailboxes`, and it was the first thing considered. The two look alike +-- from a distance — a person connects a Google account over OAuth, tokens are +-- sealed, a sweep polls it — and every column past that disagrees. `trigger_kind`, +-- `seen`, `last_uid` and `project_id` all answer "what turns a message into a +-- task", and nothing here turns into a task. What is here instead is a *set*: +-- one account holds many calendars, and a person picks which of them to look at. +-- `mailboxes` has no room for a one-to-many, and giving it one would push the +-- other kind's columns onto rows that can never use them. +-- +-- The decisive part is smaller and more practical. `mailboxes.kind` carries +-- `CHECK (kind IN ('gmail', 'imap'))`, and SQLite cannot alter a CHECK — adding a +-- third value means rebuilding the table with foreign keys off, which is the +-- expensive, careful thing migration 0021 had to do for a reason. Paying that to +-- store something that shares no column with the table is the wrong trade. +-- +-- Hence no CHECK on `provider` here. CalDAV is the next source to read, and it +-- should cost a row, not a rebuild. + +-- One connected account per person per provider. The tokens live here rather than +-- on each calendar: a refresh token copied onto ten rows is ten copies of one +-- secret, ten refreshes racing each other, and ten places to miss when access is +-- revoked. +-- +-- `refresh_token` and `access_token` are written sealed; see internal/secret. The +-- store's own seal/unseal only covers the settings tables, so this table's methods +-- call them by hand — the same fact worth knowing that migration 0014 wrote down. +CREATE TABLE calendar_accounts ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + provider TEXT NOT NULL DEFAULT 'google', + -- The address the account reports, so the settings page can say which one is + -- connected rather than only that one is. + account TEXT NOT NULL DEFAULT '', + + refresh_token TEXT NOT NULL DEFAULT '', + access_token TEXT NOT NULL DEFAULT '', + expires_at INTEGER NOT NULL DEFAULT 0, + + last_sync_at INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); + +-- Connecting again replaces what was there, the way Gmail's does: the OAuth flow +-- is one account at a time, so a second row for the same provider would be a +-- second set of tokens for the same grant and no way to say which is live. +CREATE UNIQUE INDEX calendar_accounts_once ON calendar_accounts (user_id, provider); + +-- One row per calendar the account can see, kept whether or not it is shown. +-- +-- `shown` rather than deleting the ones that are not: a person who turns a calendar +-- off and on again should get the same row back, and the list is refreshed from +-- Google on every sync — so a calendar that only existed while it was ticked would +-- lose its choice every time somebody added a new one. +-- +-- `colour` is Google's own hex for the calendar. Not one of verdande's ten named +-- colours, which is the rule everywhere else in this database: those are names so +-- the interface can restyle them per theme, and they are *verdande's* palette. A +-- Google calendar is told apart by the colour the person already knows it by, and +-- that colour belongs to Google. +CREATE TABLE calendars ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES calendar_accounts (id) ON DELETE CASCADE, + -- The id Google knows it by. Long, and an address for the primary calendar. + remote_id TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + colour TEXT NOT NULL DEFAULT '', + time_zone TEXT NOT NULL DEFAULT '', + primary_one INTEGER NOT NULL DEFAULT 0, + writable INTEGER NOT NULL DEFAULT 0, + shown INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX calendars_once ON calendars (account_id, remote_id); + +-- The events themselves, cached. +-- +-- Cached rather than fetched per view, and the reason is what a month grid does: +-- it pages. Asking Google on every arrow press means a token refresh, a round trip +-- and a spinner for a question that was answered ninety seconds ago — and a +-- calendar that is slower than the calendar it is showing is not worth having. +-- The window is refreshed by the sweep and by the "fetch now" button. +-- +-- Days rather than instants, and both ends inclusive. A grid asks one question — +-- "which cells does this cover" — and `start_day <= ? AND end_day >= ?` is that +-- question. `starts_at` is kept as Google wrote it, offset and all, because it is +-- what the chip shows and reparsing it here would ask *this process* what time +-- zone it is in. An all-day event has neither: it has a day, not a moment, and +-- inventing midnight for it is how an event moves a day when a container moves +-- time zone. +CREATE TABLE calendar_events ( + id TEXT PRIMARY KEY, + calendar_id TEXT NOT NULL REFERENCES calendars (id) ON DELETE CASCADE, + remote_id TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + starts_at TEXT NOT NULL DEFAULT '', + ends_at TEXT NOT NULL DEFAULT '', + start_day TEXT NOT NULL, + end_day TEXT NOT NULL, + all_day INTEGER NOT NULL DEFAULT 0, + location TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '' +); + +-- The one query there is: everything a person can see, between two days. Ordered +-- by the account so the join has somewhere to start, then by the days the grid +-- compares against. +CREATE INDEX calendar_events_by_day ON calendar_events (calendar_id, start_day, end_day);