Push the week's plan to Google Calendar (Feature 16)
CI / ci (push) Successful in 3m52s
CI / ci (pull_request) Successful in 3m52s

POST /api/v1/plans/{weekStart}/push-calendar writes one event per plan
day to the chosen calendar: "Dinner: {name}" from the household dinner
time for 60 minutes in the household zone, with the dinner name,
"Start by {time}" for crock-pot dinners and the
{MEALQUEST_PUBLIC_URL}/dinner/{id} link. Event ids are the lowercase
base32hex SHA-256 of "mealquest|{weekStart}|{date}"; each event is
inserted, and a 409 updates it instead.

gcal.Service.PushEvents does the Calendar writes under the account lock,
with the same redaction as listing. plan.Service.Push records a push only
when every event was written: the plan becomes synced with
calendar_synced_at and each day stores its event id. A partial failure
records nothing; a plan edited mid-push is 409 plan_changed.

A swap, or a redraft to another dinner, clears the day's
calendar_event_id, so the next push writes just those days; a push with
none pending writes every day again.

No calendar chosen is 409 no_calendar; a deleted or read-only chosen
calendar is 409 calendar_unavailable; a refused grant is 502
reconnect_required, leaving the connection as it is.

Maestro task 5885d2b1-cf06-44ff-abf3-242d7763c3c9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 22:12:44 -04:00
co-authored by Claude Opus 5.5
parent 482bec9727
commit 691c1ff34a
20 changed files with 1765 additions and 71 deletions
+15 -6
View File
@@ -105,7 +105,11 @@ func (s *Service) SelectCalendar(ctx context.Context, id string) (Selection, err
// calendars lists the writable calendars, each once, with the stored refresh
// token. The caller holds s.account.
func (s *Service) calendars(ctx context.Context) ([]Calendar, error) {
access, err := s.accessToken(ctx)
row, err := s.connection(ctx)
if err != nil {
return nil, err
}
access, err := s.accessToken(ctx, row)
if err != nil {
return nil, err
}
@@ -136,16 +140,21 @@ func (s *Service) calendars(ctx context.Context) ([]Calendar, error) {
return nil, &CalendarError{msg: fmt.Sprintf("gcal: Google listed more than %d pages of calendars", maxCalendarPages)}
}
// accessToken trades the stored refresh token for an access token. A grant
// Google reports as invalid_grant is ErrReconnect.
func (s *Service) accessToken(ctx context.Context) (string, error) {
// connection reads the connected account, or ErrNotConnected.
func (s *Service) connection(ctx context.Context) (db.IntegrationsGoogle, error) {
row, err := db.New(s.st.Reader).GetIntegrationsGoogle(ctx)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotConnected
return db.IntegrationsGoogle{}, ErrNotConnected
}
if err != nil {
return "", fmt.Errorf("gcal: read Google integration: %w", err)
return db.IntegrationsGoogle{}, fmt.Errorf("gcal: read Google integration: %w", err)
}
return row, nil
}
// accessToken trades row's refresh token for an access token. A grant
// Google reports as invalid_grant is ErrReconnect.
func (s *Service) accessToken(ctx context.Context, row db.IntegrationsGoogle) (string, error) {
refresh, err := s.box.Decrypt(row.RefreshTokenCt)
if err != nil {
return "", fmt.Errorf("gcal: open refresh token: %w", err)
+4 -4
View File
@@ -109,8 +109,8 @@ type Config struct {
CalendarAPI string
}
// Service connects and disconnects the household's Google account and
// chooses the calendar MealQuest pushes to.
// Service connects and disconnects the household's Google account,
// chooses the calendar MealQuest pushes to and writes events to it.
// MealQuest runs as one process, so the service's in-memory lock and pending
// states see every request.
type Service struct {
@@ -128,8 +128,8 @@ type Service struct {
// account serializes every use of and change to the connected account —
// a connect's exchange and store, a disconnect's read, revoke and delete,
// a calendar listing's read and Calendar API calls, a calendar choice's
// listing and store — across the requests to Google and the database
// write. Google revokes the whole grant, so a revoke overlapping a
// listing and store, an event push's read and event writes — across the
// requests to Google and the database write. Google revokes the whole grant, so a revoke overlapping a
// connect would also kill the token the connect stores.
account sync.Mutex
+208
View File
@@ -0,0 +1,208 @@
package gcal
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"time"
)
// maxEventBody caps how much of an events response is read.
const maxEventBody = 64 << 10
var (
// ErrNoCalendar means Google Calendar is connected but no calendar is
// chosen to push to.
ErrNoCalendar = errors.New("gcal: no calendar is chosen for Push to Calendar")
// ErrCalendarUnavailable means the chosen calendar was deleted or the
// connected account can no longer add events to it. Such a calendar
// cannot be chosen again; the household must choose another.
ErrCalendarUnavailable = errors.New("gcal: the chosen calendar was deleted or MealQuest can no longer add dinners to it")
)
// Google's rule for a client-chosen event id: base32hex characters
// (lowercase a-v and 0-9), 5 to 1024 of them.
var eventIDChars = regexp.MustCompile(`^[a-v0-9]+$`)
const (
minEventID = 5
maxEventID = 1024
)
// Event is one event to write to the chosen calendar.
type Event struct {
// ID is the event's id, chosen by the caller so that writing the same
// event again updates it; it must satisfy Google's id rule.
ID string
Summary string
Description string
Start, End time.Time
// TimeZone is the IANA zone the event is in, such as America/New_York.
TimeZone string
}
func (e Event) validate() error {
switch {
case len(e.ID) < minEventID, len(e.ID) > maxEventID, !eventIDChars.MatchString(e.ID):
return fmt.Errorf("gcal: event id %q is not 5 to 1024 base32hex characters", e.ID)
case e.Summary == "":
return fmt.Errorf("gcal: event %s has no summary", e.ID)
case !e.End.After(e.Start):
return fmt.Errorf("gcal: event %s does not end after it starts", e.ID)
case e.TimeZone == "":
return fmt.Errorf("gcal: event %s has no time zone", e.ID)
}
return nil
}
// eventBody is an event as the Calendar API takes it.
type eventBody struct {
ID string `json:"id"`
Status string `json:"status"`
Summary string `json:"summary"`
Description string `json:"description"`
Start eventTime `json:"start"`
End eventTime `json:"end"`
}
type eventTime struct {
DateTime string `json:"dateTime"`
TimeZone string `json:"timeZone"`
}
// PushEvents writes events, in order, to the chosen calendar: each is
// inserted with its id, and an id Google already has (HTTP 409, which
// includes an event deleted in Calendar) is updated in place instead, so
// writing an event again never adds a second one. It stops at the first
// failure; the events before it stay written, and writing them all again
// is safe. Nothing connected is ErrNotConnected, no calendar chosen is
// ErrNoCalendar, a chosen calendar that was deleted or is no longer
// writable is ErrCalendarUnavailable, a grant Google no longer accepts is
// ErrReconnect, and any other Google failure is a *CalendarError.
func (s *Service) PushEvents(ctx context.Context, events []Event) error {
if len(events) == 0 {
return errors.New("gcal: no events to push")
}
for _, e := range events {
if err := e.validate(); err != nil {
return err
}
}
s.account.Lock()
defer s.account.Unlock()
row, err := s.connection(ctx)
if err != nil {
return err
}
if !row.CalendarID.Valid || row.CalendarID.String == "" {
return ErrNoCalendar
}
access, err := s.accessToken(ctx, row)
if err != nil {
return err
}
base := s.calendarAPI + "/calendars/" + url.PathEscape(row.CalendarID.String) + "/events"
for _, e := range events {
if err := s.writeEvent(ctx, access, base, e); err != nil {
return err
}
}
return nil
}
// writeEvent inserts e at base, the chosen calendar's events collection,
// and updates it when Google already has its id.
func (s *Service) writeEvent(ctx context.Context, access, base string, e Event) error {
body, err := json.Marshal(eventBody{
ID: e.ID,
Status: "confirmed",
Summary: e.Summary,
Description: e.Description,
Start: eventTime{DateTime: e.Start.Format(time.RFC3339), TimeZone: e.TimeZone},
End: eventTime{DateTime: e.End.Format(time.RFC3339), TimeZone: e.TimeZone},
})
if err != nil {
return fmt.Errorf("gcal: encode event %s: %w", e.ID, err)
}
status, err := s.eventRequest(ctx, access, http.MethodPost, base, body)
if err != nil {
return err
}
if status != http.StatusConflict {
return nil
}
// The status "confirmed" in the body restores an event deleted in
// Calendar, which Google keeps as cancelled under the same id.
_, err = s.eventRequest(ctx, access, http.MethodPut, base+"/"+url.PathEscape(e.ID), body)
return err
}
// eventRequest sends an event insert (POST) or update (PUT) and returns
// HTTP 200 or, for an insert, 409; every other answer is an error.
func (s *Service) eventRequest(ctx context.Context, access, method, target string, body []byte) (int, error) {
what := "event insert"
if method == http.MethodPut {
what = "event update"
}
req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
if err != nil {
return 0, fmt.Errorf("gcal: build %s request: %w", what, err)
}
req.Header.Set("Authorization", "Bearer "+access)
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
var transport *url.Error
if errors.As(err, &transport) {
// The URL is dropped: after a refused redirect it is the
// Google-supplied Location.
return 0, &CalendarError{msg: fmt.Sprintf("gcal: %s request failed: %s: %v", what, transport.Op, transport.Err)}
}
return 0, &CalendarError{msg: fmt.Sprintf("gcal: %s request failed", what)}
}
defer resp.Body.Close()
reply, err := io.ReadAll(io.LimitReader(resp.Body, maxEventBody))
if err != nil {
return 0, &CalendarError{msg: fmt.Sprintf("gcal: read %s response failed", what)}
}
switch {
case resp.StatusCode == http.StatusOK:
return http.StatusOK, nil
case resp.StatusCode == http.StatusConflict && method == http.MethodPost:
return http.StatusConflict, nil
case resp.StatusCode == http.StatusUnauthorized:
return 0, ErrReconnect
case resp.StatusCode == http.StatusNotFound && method == http.MethodPost,
resp.StatusCode == http.StatusForbidden && errorReason(reply) == "requiredAccessLevel":
// An insert into a deleted calendar, or one the account no longer
// sees, is not found; a write to one it can only read is
// requiredAccessLevel. An update not found is the event missing
// from the calendar after Google reported its id taken, which
// says nothing about the calendar.
return 0, ErrCalendarUnavailable
}
return 0, &CalendarError{msg: fmt.Sprintf("gcal: Google refused the %s (HTTP %d)", what, resp.StatusCode)}
}
// errorReason is the reason of the first error in a Calendar API error
// body, or "" when there is none. Only the reason is read; the rest of
// Google's text is never kept.
func errorReason(body []byte) string {
var reply struct {
Error struct {
Errors []struct {
Reason string `json:"reason"`
} `json:"errors"`
} `json:"error"`
}
if json.Unmarshal(body, &reply) != nil || len(reply.Error.Errors) == 0 {
return ""
}
return reply.Error.Errors[0].Reason
}
+156
View File
@@ -0,0 +1,156 @@
package gcal_test
import (
"context"
"errors"
"net/http"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
)
// chosen is a harness connected with dinners chosen as the calendar to
// push to.
func chosen(t *testing.T) *harness {
t.Helper()
h := connected(t)
if _, err := h.svc.SelectCalendar(context.Background(), dinners.ID); err != nil {
t.Fatal(err)
}
return h
}
// event is a valid event with id.
func event(id string) gcal.Event {
start := time.Date(2026, 10, 12, 21, 30, 0, 0, time.UTC)
return gcal.Event{ID: id, Summary: "Dinner: Beef Chili", Description: "Beef Chili",
Start: start, End: start.Add(time.Hour), TimeZone: "America/New_York"}
}
func TestPushEventsInsertsThenUpdates(t *testing.T) {
t.Parallel()
h := chosen(t)
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde0"), event("abcde1")}); err != nil {
t.Fatalf("PushEvents: %v", err)
}
renamed := event("abcde0")
renamed.Summary = "Dinner: Pot Roast"
if err := h.svc.PushEvents(context.Background(), []gcal.Event{renamed}); err != nil {
t.Fatalf("PushEvents again: %v", err)
}
if inserts, updates := h.google.EventWrites(); inserts != 2 || updates != 1 {
t.Errorf("%d inserts and %d updates, want 2 and 1", inserts, updates)
}
events := h.google.Events(dinners.ID)
if len(events) != 2 || events["abcde0"].Summary != "Dinner: Pot Roast" {
t.Errorf("events = %+v", events)
}
if got := events["abcde1"].Start.DateTime; got != "2026-10-12T21:30:00Z" {
t.Errorf("start = %s, want the instant as given", got)
}
}
func TestPushEventsRefusesInvalidEvents(t *testing.T) {
t.Parallel()
h := chosen(t)
before := h.google.TokenCalls()
for name, edit := range map[string]func(*gcal.Event){
"uppercase id": func(e *gcal.Event) { e.ID = "ABCDE" },
"id outside a-v": func(e *gcal.Event) { e.ID = "abcdw" },
"id too short": func(e *gcal.Event) { e.ID = "abcd" },
"id too long": func(e *gcal.Event) { e.ID = strings.Repeat("a", 1025) },
"no summary": func(e *gcal.Event) { e.Summary = "" },
"ends as it starts": func(e *gcal.Event) { e.End = e.Start },
"no time zone": func(e *gcal.Event) { e.TimeZone = "" },
} {
ev := event("abcde")
edit(&ev)
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde0"), ev}); err == nil {
t.Errorf("%s: PushEvents succeeded", name)
}
}
if err := h.svc.PushEvents(context.Background(), nil); err == nil {
t.Error("PushEvents of no events succeeded")
}
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event(strings.Repeat("v", 1024))}); err != nil {
t.Errorf("PushEvents of a 1024-character id: %v", err)
}
if inserts, _ := h.google.EventWrites(); inserts != 1 {
t.Errorf("%d events inserted, want only the valid one", inserts)
}
if n := h.google.TokenCalls() - before; n != 1 {
t.Errorf("token endpoint called %d times, want once, for the valid push", n)
}
}
func TestPushEventsGrantNoLongerAccepted(t *testing.T) {
t.Parallel()
h := chosen(t)
h.google.FailEventWrite(1, http.StatusUnauthorized)
err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")})
if !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("PushEvents = %v, want ErrReconnect", err)
}
assertNoSecret(t, "error", err.Error())
if _, ok := h.row(t); !ok {
t.Error("a refused grant forgot the connection")
}
}
func TestPushEventsGoogleFailureIsRedacted(t *testing.T) {
t.Parallel()
for name, n := range map[string]int{"insert": 1, "update": 2} {
t.Run(name, func(t *testing.T) {
t.Parallel()
h := chosen(t)
if name == "update" {
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}); err != nil {
t.Fatal(err)
}
}
// The fake's failure body quotes the access token.
h.google.FailEventWrite(n, http.StatusInternalServerError)
err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")})
var calendar *gcal.CalendarError
if !errors.As(err, &calendar) {
t.Fatalf("PushEvents = %v, want *CalendarError", err)
}
if !strings.Contains(err.Error(), "event "+name) || !strings.Contains(err.Error(), "HTTP 500") {
t.Errorf("error = %q, want the failed %s and its status", err, name)
}
assertNoSecret(t, "error", err.Error())
})
}
}
// TestPushEventsHoldsTheAccount holds an event write at Google while a
// disconnect starts: the disconnect waits for the push, so it never
// revokes the token the push is using.
func TestPushEventsHoldsTheAccount(t *testing.T) {
t.Parallel()
h := chosen(t)
arrived, release := h.google.HoldEvents()
defer release()
pushed := make(chan error, 1)
go func() { pushed <- h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}) }()
<-arrived
disconnected := make(chan error, 1)
go func() { disconnected <- h.svc.Disconnect(context.Background()) }()
select {
case err := <-disconnected:
t.Fatalf("Disconnect finished (%v) while a push was at Google", err)
case <-time.After(200 * time.Millisecond):
}
if n := len(h.google.Revoked()); n != 0 {
t.Fatalf("revoked %d tokens while a push was at Google", n)
}
release()
if err := <-pushed; err != nil {
t.Errorf("PushEvents: %v", err)
}
if err := <-disconnected; err != nil {
t.Errorf("Disconnect: %v", err)
}
}
+220 -2
View File
@@ -3,8 +3,12 @@
// client, the redirect URI and PKCE, issues a distinct refresh token per
// exchange and trades a live refresh token for an access token; a revoke
// endpoint that, like Google's, revokes the whole grant: every token issued
// so far; and a calendarList endpoint that requires a live grant's access
// token. It never touches the network beyond its own httptest server.
// so far; a calendarList endpoint that requires a live grant's access
// token; and events insert and update endpoints that, like Google's, keep
// each calendar's events by id, answer an insert of an id the calendar
// already has (even a cancelled event's) with HTTP 409, and refuse a
// calendar not listed (404) or not writable (403 requiredAccessLevel). It
// never touches the network beyond its own httptest server.
package gcaltest
import (
@@ -23,6 +27,7 @@ import (
"golang.org/x/oauth2"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
)
@@ -74,6 +79,32 @@ type Google struct {
// body that quotes the access token.
calendarStatus int
calendarCalls int
// events holds each calendar's events by id.
events map[string]map[string]Event
// inserts and updates count the events created and updated.
inserts, updates int
// eventWrites counts the requests that reached the events endpoints;
// failWrite, when not 0, fails the request that makes it that count
// with failStatus and a body that quotes the access token.
eventWrites, failWrite, failStatus int
// eventHold, when set, stops the next events request.
eventHold *hold
}
// Event is an event as the fake keeps it: what MealQuest sent.
type Event struct {
ID string `json:"id"`
Status string `json:"status"`
Summary string `json:"summary"`
Description string `json:"description"`
Start EventTime `json:"start"`
End EventTime `json:"end"`
}
// EventTime is an event's start or end.
type EventTime struct {
DateTime string `json:"dateTime"`
TimeZone string `json:"timeZone"`
}
// Calendar is one calendarList entry. The fake answers each with Google's
@@ -106,11 +137,14 @@ func New(t *testing.T, redirectURL string) *Google {
calendarStatus: http.StatusOK,
codes: map[string]string{},
dead: map[string]bool{},
events: map[string]map[string]Event{},
}
mux := http.NewServeMux()
mux.HandleFunc("POST /token", g.token)
mux.HandleFunc("POST /revoke", g.revoke)
mux.HandleFunc("GET /calendar/v3/users/me/calendarList", g.calendarList)
mux.HandleFunc("POST /calendar/v3/calendars/{calendar}/events", g.insertEvent)
mux.HandleFunc("PUT /calendar/v3/calendars/{calendar}/events/{event}", g.updateEvent)
g.Server = httptest.NewServer(mux)
t.Cleanup(g.Server.Close)
return g
@@ -142,6 +176,33 @@ func (g *Google) Authorize(challenge string) string {
return code
}
// Connect connects svc to the fake as a household completing the consent
// screen does. svc must use the fake's endpoints and redirect URL.
func (g *Google) Connect(t *testing.T, svc *gcal.Service) {
t.Helper()
session := auth.Session{Subject: "gcaltest", CSRFToken: "gcaltest-session"}
started := httptest.NewRecorder()
raw, err := svc.Connect(started, session)
if err != nil {
t.Fatal(err)
}
consent, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
q := url.Values{
"state": {consent.Query().Get("state")},
"code": {g.Authorize(consent.Query().Get("code_challenge"))},
}
req := httptest.NewRequest(http.MethodGet, gcal.CallbackPath+"?"+q.Encode(), nil)
for _, c := range started.Result().Cookies() {
req.AddCookie(c)
}
if err := svc.Complete(httptest.NewRecorder(), req, session); err != nil {
t.Fatalf("connect: %v", err)
}
}
// OmitRefreshToken makes the token endpoint answer without a refresh token,
// as Google does when it reuses an earlier grant.
func (g *Google) OmitRefreshToken() {
@@ -216,6 +277,59 @@ func (g *Google) HoldCalendarList() (arrived <-chan struct{}, release func()) {
return g.calendarHold.arrived, g.calendarHold.releaseFunc()
}
// Events returns the events of calendar, by id.
func (g *Google) Events(calendar string) map[string]Event {
g.mu.Lock()
defer g.mu.Unlock()
out := map[string]Event{}
for id, e := range g.events[calendar] {
out[id] = e
}
return out
}
// EventWrites returns how many events were inserted and how many updated.
func (g *Google) EventWrites() (inserts, updates int) {
g.mu.Lock()
defer g.mu.Unlock()
return g.inserts, g.updates
}
// ResetEventWrites zeroes the insert and update counts.
func (g *Google) ResetEventWrites() {
g.mu.Lock()
defer g.mu.Unlock()
g.inserts, g.updates = 0, 0
}
// CancelEvent marks event id of calendar cancelled, as deleting it in
// Google Calendar does: Google keeps the id.
func (g *Google) CancelEvent(calendar, id string) {
g.mu.Lock()
defer g.mu.Unlock()
e := g.events[calendar][id]
e.Status = "cancelled"
g.events[calendar][id] = e
}
// FailEventWrite makes the nth request to the events endpoints from now
// (1 for the next) answer status with a body that quotes the access token.
func (g *Google) FailEventWrite(n, status int) {
g.mu.Lock()
defer g.mu.Unlock()
g.failWrite, g.failStatus = g.eventWrites+n, status
}
// HoldEvents stops the next request to the events endpoints before it is
// answered. It returns a channel closed when the request arrives and the
// function that lets it go on.
func (g *Google) HoldEvents() (arrived <-chan struct{}, release func()) {
g.mu.Lock()
defer g.mu.Unlock()
g.eventHold = newHold()
return g.eventHold.arrived, g.eventHold.releaseFunc()
}
// TokenCalls is how many requests, exchanges and refreshes, reached the
// token endpoint.
func (g *Google) TokenCalls() int {
@@ -426,6 +540,110 @@ func (g *Google) calendarList(w http.ResponseWriter, r *http.Request) {
}
}
// insertEvent answers like Google's events.insert with a client-chosen id.
func (g *Google) insertEvent(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
cal, ok := g.eventCalendar(w, r)
if !ok {
return
}
var e Event
if err := json.NewDecoder(r.Body).Decode(&e); err != nil || e.ID == "" {
http.Error(w, "bad event", http.StatusBadRequest)
return
}
if _, exists := g.events[cal][e.ID]; exists {
apiError(w, http.StatusConflict, "duplicate", "The requested identifier already exists.")
return
}
if g.events[cal] == nil {
g.events[cal] = map[string]Event{}
}
g.events[cal][e.ID] = e
g.inserts++
writeEvent(w, e)
}
// updateEvent answers like Google's events.update.
func (g *Google) updateEvent(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
cal, ok := g.eventCalendar(w, r)
if !ok {
return
}
id := r.PathValue("event")
if _, exists := g.events[cal][id]; !exists {
apiError(w, http.StatusNotFound, "notFound", "Not Found")
return
}
var e Event
if err := json.NewDecoder(r.Body).Decode(&e); err != nil || e.ID != id {
http.Error(w, "bad event", http.StatusBadRequest)
return
}
g.events[cal][id] = e
g.updates++
writeEvent(w, e)
}
// eventCalendar runs the checks every events request shares: the hold, a
// live grant's access token, a scripted failure, and a listed, writable
// calendar, which it returns. When a check fails it answers and returns
// false. The caller holds g.mu.
func (g *Google) eventCalendar(w http.ResponseWriter, r *http.Request) (string, bool) {
g.eventWrites++
h := g.eventHold
g.eventHold = nil
g.wait(h)
auth := r.Header.Get("Authorization")
if auth != "Bearer "+AccessToken || !g.grantLive() {
apiError(w, http.StatusUnauthorized, "authError", "Invalid Credentials: "+auth)
return "", false
}
if g.failWrite != 0 && g.eventWrites == g.failWrite {
apiError(w, g.failStatus, "backendError", "echo "+auth)
return "", false
}
cal := r.PathValue("calendar")
i := slices.IndexFunc(g.calendars, func(c Calendar) bool { return c.ID == cal })
switch {
case i < 0:
apiError(w, http.StatusNotFound, "notFound", "Not Found")
return "", false
case g.calendars[i].AccessRole != "writer" && g.calendars[i].AccessRole != "owner":
apiError(w, http.StatusForbidden, "requiredAccessLevel", "You need to have writer access to this calendar.")
return "", false
}
return cal, true
}
// writeEvent answers with e as Google does, with fields of its own.
func writeEvent(w http.ResponseWriter, e Event) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]any{
"kind": "calendar#event", "etag": `"3300000000000000"`, "id": e.ID, "status": e.Status,
"htmlLink": "https://www.google.com/calendar/event?eid=" + e.ID,
"summary": e.Summary, "description": e.Description, "start": e.Start, "end": e.End,
"iCalUID": e.ID + "@google.com", "sequence": 0,
}); err != nil {
panic(err)
}
}
// apiError answers a Calendar API error with reason and message.
func apiError(w http.ResponseWriter, status int, reason, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{
"code": status, "message": message,
"errors": []map[string]any{{"domain": "global", "reason": reason, "message": message}},
}}); err != nil {
panic(err)
}
}
// grantLive reports whether any refresh token issued is still live. The
// caller holds g.mu.
func (g *Google) grantLive() bool {