From 691c1ff34abfd1ee182053ad0067077a690ac148 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 22:12:44 -0400 Subject: [PATCH] Push the week's plan to Google Calendar (Feature 16) 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 --- cmd/server/main.go | 10 +- docs/ARCHITECTURE.md | 46 ++- internal/db/plans.sql.go | 83 ++++- internal/db/querier.go | 17 +- internal/gcal/calendars.go | 21 +- internal/gcal/connect.go | 8 +- internal/gcal/events.go | 208 +++++++++++++ internal/gcal/events_test.go | 156 ++++++++++ internal/gcal/gcaltest/gcaltest.go | 222 ++++++++++++- internal/plan/draft.go | 7 +- internal/plan/plan.go | 31 +- internal/plan/plan_test.go | 58 +++- internal/plan/push.go | 217 +++++++++++++ internal/plan/push_test.go | 479 +++++++++++++++++++++++++++++ internal/server/plans.go | 53 ++++ internal/server/plans_test.go | 173 ++++++++++- internal/server/server.go | 1 + internal/server/server_test.go | 2 +- internal/suggest/suggest_test.go | 11 +- migrations/queries/plans.sql | 33 +- 20 files changed, 1765 insertions(+), 71 deletions(-) create mode 100644 internal/gcal/events.go create mode 100644 internal/gcal/events_test.go create mode 100644 internal/plan/push.go create mode 100644 internal/plan/push_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 4f984b5..51b105b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -114,11 +114,6 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, logger) - if err != nil { - return err - } - googleSvc, err := gcal.NewService(db, box, gcal.Config{ ClientID: cfg.GoogleClientID, ClientSecret: cfg.GoogleClientSecret.Reveal(), @@ -132,6 +127,11 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } + planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, cfg.PublicURL, logger) + if err != nil { + return err + } + sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax) if err != nil { return err diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 10bd452..ef69fb5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -100,7 +100,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`). | `suggestion_batches` | trigger, status, ai_run_id. | | `suggestions` | dinner_id, batch_id, name (the name the dish was suggested under, kept whatever its dinner is later renamed, so a Pass stays an immutable record every later batch dedupes against), reason, cited_regular_ids (JSON), status, decided_at. | | `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at, revision (raised by every swap and stored draft). | -| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. | +| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while Calendar shows the day as planned; cleared when its dinner changes). | | `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. | | `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. | | `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. | @@ -350,8 +350,9 @@ automatic** (D-021). 409 `draft_running` and enqueues nothing. The job runs the `draft_week` skill on every Regular and every Quest log dinner no other plan for the current or a later week has, with the household context and last week's dinners, and stores the - week: a new plan is `draft`; a redraft keeps each day's - `calendar_event_id`, and a `synced` or `changed` plan becomes `changed`. + week: a new plan is `draft`; a redraft keeps the `calendar_event_id` of + each day whose dinner stays the same and clears it for the others; a + `synced` or `changed` plan becomes `changed`. Drafting over a `synced` or `changed` plan needs `{"confirm": true}`, else 409 `confirm_required`; a plan synced while an unconfirmed draft runs is left as it is and the job fails `confirm_required`. The job reads the @@ -366,8 +367,8 @@ automatic** (D-021). dinners fails the job `not_enough_dinners`, draft_week failing `ai_failed`. `PUT /api/v1/plans/{weekStart}/days/{date}` takes `{dinner_id}`, a Regular or Quest log dinner, swaps the day, recomputes - `is_new`, moves a `synced` plan to `changed`, and answers 200 with the - plan. A date outside the week is 400 `invalid_date`, an unknown dinner or + `is_new`, clears the day's `calendar_event_id`, moves a `synced` plan to + `changed`, and answers 200 with the plan. A date outside the week is 400 `invalid_date`, an unknown dinner or a week with no plan 404, any other dinner 409 `dinner_not_plannable`, and a dinner already on another day of the week 409 `dinner_already_planned`. - Rotation check (`plan.Service`, D-021). This release provides the @@ -667,8 +668,39 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. leave a stored token Google has already revoked. - No response, log line or error ever holds a token or Google's response text. -- **Deterministic event ids**; each plan day records its - `calendar_event_id`. +- Push to Calendar: `POST /api/v1/plans/{weekStart}/push-calendar` + (CSRF-checked, `plan.Service.Push` writing through + `gcal.Service.PushEvents`) answers 200 with the plan as synced. Each day + is one event in the chosen calendar: summary `Dinner: {name}`, from the + household `dinner_time` on that date for 60 minutes, as `dateTime` with + the household `timeZone` (so its UTC instant follows a DST change within + the week), and a description of the dinner's name, `Start by {time}` for + a crock-pot dinner of known minutes, and + `{MEALQUEST_PUBLIC_URL}/dinner/{id}`. + - **Deterministic event ids**: lowercase unpadded base32hex of SHA-256 of + `mealquest|{weekStart}|{date}` — 52 characters of `a`–`v` and `0`–`9`, + within Google's id rule. Each event is inserted; a 409 (the id exists, + even as an event deleted in Calendar) updates it instead, with status + `confirmed`, so pushing again never adds a second event. + - A push writes the days whose `calendar_event_id` is empty (never + pushed, swapped, or redrafted to another dinner). When none is, it + writes every day again, which puts back anything changed in Calendar. + - Only a push that wrote all its events is recorded, in one transaction: + the plan becomes `synced` with `calendar_synced_at`, and each day + written stores its event id. A push that failed part way records + nothing and leaves the status as it was; pushing again updates the + events already written. A plan swapped or redrafted while it was being + pushed (its revision moved) is not recorded: 409 `plan_changed`. + - Errors: 409 `not_connected`, 409 `no_calendar` (connected, no calendar + chosen), 409 `calendar_unavailable` (the chosen calendar was deleted — + Google 404 — or is no longer writable — 403 `requiredAccessLevel`; + choose another), 502 `reconnect_required` (the grant is refused; the + connection is left as it is), 502 `google_failed`, 404 when the week + has no plan, 400 `invalid_week`. The push holds the account lock + across its Google requests, like listing and choosing. + - The week's Supply run (Feature 17) is made from a pushed plan: a + successful `Push` returns that plan, which is where Feature 17 hooks + in. Nothing here generates a shopping list. ## Frontend diff --git a/internal/db/plans.sql.go b/internal/db/plans.sql.go index bf97ec5..60acaa8 100644 --- a/internal/db/plans.sql.go +++ b/internal/db/plans.sql.go @@ -154,7 +154,7 @@ func (q *Queries) ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([ } const listPlanDays = `-- name: ListPlanDays :many -SELECT plan_days.date, plan_days.is_new, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision +SELECT plan_days.date, plan_days.is_new, plan_days.calendar_event_id, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision FROM plan_days JOIN dinners ON dinners.id = plan_days.dinner_id WHERE plan_days.plan_id = ? @@ -162,9 +162,10 @@ ORDER BY plan_days.date ` type ListPlanDaysRow struct { - Date string - IsNew int64 - Dinner Dinner + Date string + IsNew int64 + CalendarEventID sql.NullString + Dinner Dinner } // The days of plan plan_id with their dinners, Monday first. A day with @@ -181,6 +182,7 @@ func (q *Queries) ListPlanDays(ctx context.Context, planID string) ([]ListPlanDa if err := rows.Scan( &i.Date, &i.IsNew, + &i.CalendarEventID, &i.Dinner.ID, &i.Dinner.Name, &i.Dinner.NormalizedName, @@ -267,11 +269,42 @@ func (q *Queries) MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEdited return err } +const markWeekPlanSynced = `-- name: MarkWeekPlanSynced :execrows +UPDATE week_plans +SET status = 'synced', calendar_synced_at = ?, updated_at = ? +WHERE id = ? AND revision = ? +` + +type MarkWeekPlanSyncedParams struct { + CalendarSyncedAt sql.NullString + UpdatedAt string + ID string + Revision int64 +} + +// Records a Push to Calendar of plan id as it was at revision: the plan +// becomes synced. A plan edited since (another revision) is left as it is +// (0 rows). +func (q *Queries) MarkWeekPlanSynced(ctx context.Context, arg MarkWeekPlanSyncedParams) (int64, error) { + result, err := q.db.ExecContext(ctx, markWeekPlanSynced, + arg.CalendarSyncedAt, + arg.UpdatedAt, + arg.ID, + arg.Revision, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const putPlanDay = `-- name: PutPlanDay :exec INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (plan_id, date) DO UPDATE -SET dinner_id = excluded.dinner_id, is_new = excluded.is_new, updated_at = excluded.updated_at +SET dinner_id = excluded.dinner_id, is_new = excluded.is_new, + calendar_event_id = CASE WHEN plan_days.dinner_id IS excluded.dinner_id THEN plan_days.calendar_event_id END, + updated_at = excluded.updated_at ` type PutPlanDayParams struct { @@ -284,8 +317,9 @@ type PutPlanDayParams struct { UpdatedAt string } -// Plans dinner_id on date of plan_id, keeping the day's calendar event id -// when the day exists. +// Plans dinner_id on date of plan_id. An existing day keeps its calendar +// event id while its dinner stays the same; a new dinner clears it, since +// Calendar no longer shows the day as planned. func (q *Queries) PutPlanDay(ctx context.Context, arg PutPlanDayParams) error { _, err := q.db.ExecContext(ctx, putPlanDay, arg.ID, @@ -324,9 +358,37 @@ func (q *Queries) RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams return err } +const setPlanDayCalendarEvent = `-- name: SetPlanDayCalendarEvent :execrows +UPDATE plan_days +SET calendar_event_id = ?, updated_at = ? +WHERE plan_id = ? AND date = ? +` + +type SetPlanDayCalendarEventParams struct { + CalendarEventID sql.NullString + UpdatedAt string + PlanID string + Date string +} + +// Records that the event calendar_event_id on Calendar shows date of +// plan_id as planned. +func (q *Queries) SetPlanDayCalendarEvent(ctx context.Context, arg SetPlanDayCalendarEventParams) (int64, error) { + result, err := q.db.ExecContext(ctx, setPlanDayCalendarEvent, + arg.CalendarEventID, + arg.UpdatedAt, + arg.PlanID, + arg.Date, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const swapPlanDay = `-- name: SwapPlanDay :execrows UPDATE plan_days -SET dinner_id = ?, is_new = ?, updated_at = ? +SET dinner_id = ?, is_new = ?, calendar_event_id = NULL, updated_at = ? WHERE plan_id = ? AND date = ? ` @@ -338,8 +400,9 @@ type SwapPlanDayParams struct { Date string } -// Plans dinner_id on an existing day of plan_id; a date the plan does not -// have is left as it is (0 rows). +// Plans dinner_id on an existing day of plan_id, clearing its calendar +// event id since Calendar no longer shows the day as planned; a date the +// plan does not have is left as it is (0 rows). func (q *Queries) SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error) { result, err := q.db.ExecContext(ctx, swapPlanDay, arg.DinnerID, diff --git a/internal/db/querier.go b/internal/db/querier.go index c80d8fe..c8bc416 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -176,6 +176,10 @@ type Querier interface { // Records an edit of plan id, raising its revision: a synced plan becomes // changed, any other keeps its status. MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error + // Records a Push to Calendar of plan id as it was at revision: the plan + // becomes synced. A plan edited since (another revision) is left as it is + // (0 rows). + MarkWeekPlanSynced(ctx context.Context, arg MarkWeekPlanSyncedParams) (int64, error) // Moves a dinner still suggested to status (quest_log or archived), // recording updated_by; a dinner no longer suggested is left as it is // (0 rows). @@ -186,8 +190,9 @@ type Querier interface { MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error) // Stores a partial result of a running job. PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error) - // Plans dinner_id on date of plan_id, keeping the day's calendar event id - // when the day exists. + // Plans dinner_id on date of plan_id. An existing day keeps its calendar + // event id while its dinner stays the same; a new dinner clears it, since + // Calendar no longer shows the day as planned. PutPlanDay(ctx context.Context, arg PutPlanDayParams) error // Records a new draft over plan id: its status and note, raising its // revision. @@ -196,12 +201,16 @@ type Querier interface { SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) // Sets the chosen calendar; affects no row when nothing is connected. SetIntegrationsGoogleCalendar(ctx context.Context, arg SetIntegrationsGoogleCalendarParams) (int64, error) + // Records that the event calendar_event_id on Calendar shows date of + // plan_id as planned. + SetPlanDayCalendarEvent(ctx context.Context, arg SetPlanDayCalendarEventParams) (int64, error) // Marks a pending batch running; a batch in any other status is left as // it is (0 rows). StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error) SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error) - // Plans dinner_id on an existing day of plan_id; a date the plan does not - // have is left as it is (0 rows). + // Plans dinner_id on an existing day of plan_id, clearing its calendar + // event id since Calendar no longer shows the day as planned; a date the + // plan does not have is left as it is (0 rows). SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error) // An unknown id returns sql.ErrNoRows. ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error) diff --git a/internal/gcal/calendars.go b/internal/gcal/calendars.go index c7c3961..0d416cd 100644 --- a/internal/gcal/calendars.go +++ b/internal/gcal/calendars.go @@ -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) diff --git a/internal/gcal/connect.go b/internal/gcal/connect.go index 12e202a..1311560 100644 --- a/internal/gcal/connect.go +++ b/internal/gcal/connect.go @@ -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 diff --git a/internal/gcal/events.go b/internal/gcal/events.go new file mode 100644 index 0000000..e021ce4 --- /dev/null +++ b/internal/gcal/events.go @@ -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 +} diff --git a/internal/gcal/events_test.go b/internal/gcal/events_test.go new file mode 100644 index 0000000..fb238d2 --- /dev/null +++ b/internal/gcal/events_test.go @@ -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) + } +} diff --git a/internal/gcal/gcaltest/gcaltest.go b/internal/gcal/gcaltest/gcaltest.go index 4a46cbf..8a3a51e 100644 --- a/internal/gcal/gcaltest/gcaltest.go +++ b/internal/gcal/gcaltest/gcaltest.go @@ -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 { diff --git a/internal/plan/draft.go b/internal/plan/draft.go index 5ef6cbd..b1cc157 100644 --- a/internal/plan/draft.go +++ b/internal/plan/draft.go @@ -136,9 +136,10 @@ func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skill // storeDraft stores week as the plan of the week starting weekStart in one // transaction and returns the plan's id. A new plan is draft. A draft -// replaces the days of an existing plan, keeping each day's calendar -// event id; a draft plan stays draft and a synced or changed one becomes -// changed, since Calendar no longer shows it. A plan synced since the +// replaces the days of an existing plan, keeping the calendar event id of +// each day whose dinner stays the same and clearing the others'; a draft +// plan stays draft and a synced or changed one becomes changed, since +// Calendar no longer shows it. A plan synced since the // draft was asked for without confirm is left as it is, failing with // ReasonConfirmRequired, and so is a plan no longer at revision base (the // revision before draft_week ran, or noPlan), failing with diff --git a/internal/plan/plan.go b/internal/plan/plan.go index 15291d5..c016212 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -4,7 +4,7 @@ // a day from Monday to Sunday, and the job stores them as the week's plan, // replacing any draft before it unless the household edited the plan // while draft_week ran. A plan is draft until Push to Calendar -// syncs it (Feature 16); an edit of a synced plan makes it changed. +// syncs it (Push); an edit of a synced plan makes it changed. // // The Rotation check follows a plan day: MarkTried moves the Quest log // dinners planned on it to tried, and the household answers "Keep it in @@ -17,6 +17,7 @@ import ( "errors" "fmt" "log/slog" + "net/url" "slices" "time" @@ -132,19 +133,24 @@ func ParseWeekStart(s string) (store.LocalDate, error) { // skills.DraftWeek with the household's harness.Config. type Drafter func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) -// Service drafts, reads and edits weekly plans. +// Service drafts, reads, edits and pushes weekly plans. type Service struct { st *store.Store draft Drafter + cal Calendar logger *slog.Logger draftJob *jobs.Kind[DraftJob] + // publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links + // to its dinner. + publicURL *url.URL } -// NewService returns a service over st that drafts with draft and -// registers the KindDraftWeek job on runner, which must not have started -// yet. The kind is service-only: StartDraft, which keeps one draft in -// flight per week, is the only way to enqueue it. -func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, logger *slog.Logger) (*Service, error) { +// NewService returns a service over st that drafts with draft, pushes to +// cal with links under publicURL, and registers the KindDraftWeek job on +// runner, which must not have started yet. The kind is service-only: +// StartDraft, which keeps one draft in flight per week, is the only way to +// enqueue it. +func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, publicURL *url.URL, logger *slog.Logger) (*Service, error) { switch { case st == nil: return nil, errors.New("plan: nil store") @@ -152,10 +158,14 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, logger *slo return nil, errors.New("plan: nil drafter") case runner == nil: return nil, errors.New("plan: nil job runner") + case cal == nil: + return nil, errors.New("plan: nil calendar") + case publicURL == nil: + return nil, errors.New("plan: nil public URL") case logger == nil: return nil, errors.New("plan: nil logger") } - s := &Service{st: st, draft: draft, logger: logger} + s := &Service{st: st, draft: draft, cal: cal, logger: logger, publicURL: publicURL} kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...) if err != nil { return nil, err @@ -344,8 +354,9 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con } // Swap plans dinnerID on date of the week starting weekStart, marking the -// day new when the dinner is in the Quest log. Swapping a synced plan -// makes it changed. The week must have a plan (else ErrNotFound), date +// day new when the dinner is in the Quest log and clearing its calendar +// event id, so the next Push writes it. Swapping a synced plan makes it +// changed. The week must have a plan (else ErrNotFound), date // must be one of its days (else *InvalidDayError), the dinner must be a // Regular or in the Quest log (else ErrDinnerNotFound or // *DinnerNotPlannableError), and it must not be on another day of the diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index 8795f70..dd48ada 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -2,12 +2,15 @@ package plan_test import ( "context" + "crypto/rand" "database/sql" + "encoding/base64" "encoding/json" "errors" "fmt" "io" "log/slog" + "net/url" "slices" "strings" "sync" @@ -18,9 +21,12 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" ) @@ -31,6 +37,9 @@ var ( weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"} ) +// publicURL is MEALQUEST_PUBLIC_URL in these tests. +var publicURL = &url.URL{Scheme: "https", Host: "meals.example.com"} + func mustDate(s string) store.LocalDate { d, err := store.ParseLocalDate(s) if err != nil { @@ -44,6 +53,9 @@ type env struct { st *store.Store runner *jobs.Runner model *llm.Fake + // google is the fake Google the service pushes to through gcal. + google *gcaltest.Google + gcal *gcal.Service // ids are the seeded dinners' ids by name. ids map[string]string @@ -55,7 +67,8 @@ type env struct { // America/New_York with dinner time 17:30, the Regulars below and Quest // log dinners Pad Thai and Shawarma Bowls, and an unstarted job runner // with two workers. It drafts through the real harness and draft_week with -// the scripted e.model. +// the scripted e.model, and pushes through gcal to e.google, which starts +// unconnected. func newEnv(t *testing.T) *env { t.Helper() ctx := t.Context() @@ -111,7 +124,26 @@ func newEnv(t *testing.T) *env { if err != nil { t.Fatal(err) } - e.svc, err = plan.NewService(st, drafter, e.runner, logger) + e.google = gcaltest.New(t, "https://meals.example.com"+gcal.CallbackPath) + key := make([]byte, secretbox.KeySize) + rand.Read(key) + box, err := secretbox.New(base64.StdEncoding.EncodeToString(key)) + if err != nil { + t.Fatal(err) + } + e.gcal, err = gcal.NewService(st, box, gcal.Config{ + ClientID: gcaltest.ClientID, + ClientSecret: gcaltest.ClientSecret, + PublicURL: publicURL, + SecretKey: []byte("server-secret-key"), + Endpoint: e.google.Endpoint(), + RevokeURL: e.google.RevokeURL(), + CalendarAPI: e.google.CalendarAPI(), + }) + if err != nil { + t.Fatal(err) + } + e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, publicURL, logger) if err != nil { t.Fatal(err) } @@ -781,12 +813,26 @@ func TestDraftOverSyncedOrChangedPlanNeedsConfirm(t *testing.T) { if got := dayNames(p); !slices.Equal(got, reversed) { t.Errorf("days = %v, want %v", got, reversed) } - var events int - if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM plan_days WHERE calendar_event_id = 'ev-' || date`).Scan(&events); err != nil { + // Reversed, only Thursday keeps its dinner, so only Thursday + // keeps its event id: Calendar no longer shows the others as + // planned. + rows, err := e.st.Reader.QueryContext(t.Context(), `SELECT date FROM plan_days WHERE calendar_event_id = 'ev-' || date ORDER BY date`) + if err != nil { t.Fatal(err) } - if events != 7 { - t.Errorf("%d days kept their calendar event id, want 7", events) + var kept []string + for rows.Next() { + var date string + if err := rows.Scan(&date); err != nil { + t.Fatal(err) + } + kept = append(kept, date) + } + if err := errors.Join(rows.Err(), rows.Close()); err != nil { + t.Fatal(err) + } + if !slices.Equal(kept, []string{"2026-10-15"}) { + t.Errorf("days that kept their calendar event id = %v, want only the unchanged 2026-10-15", kept) } }) } diff --git a/internal/plan/push.go b/internal/plan/push.go new file mode 100644 index 0000000..06f05b8 --- /dev/null +++ b/internal/plan/push.go @@ -0,0 +1,217 @@ +package plan + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/base32" + "errors" + "fmt" + "strings" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// eventLength is how long a dinner's Calendar event lasts. +const eventLength = 60 * time.Minute + +// ErrPlanChanged is a Push to Calendar of a plan edited (swapped or +// redrafted) while it was being pushed: Calendar may show the plan as it +// was, so the push is not recorded and pushing again brings Calendar up to +// date. +var ErrPlanChanged = errors.New("the plan changed while it was being pushed to Calendar; push again") + +// Calendar writes events to the household's chosen calendar. In +// production it is *gcal.Service. +type Calendar interface { + PushEvents(ctx context.Context, events []gcal.Event) error +} + +// eventIDEncoding is base32hex, lowercase and unpadded: Google's alphabet +// for event ids. +var eventIDEncoding = base32.NewEncoding("0123456789abcdefghijklmnopqrstuv").WithPadding(base32.NoPadding) + +// EventID is the Calendar event id of date of the week starting weekStart: +// the lowercase base32hex SHA-256 of "mealquest|{weekStart}|{date}", 52 +// characters of a-v and 0-9, within Google's rule for event ids (that +// alphabet, 5 to 1024 characters). A day always has the same id, so +// pushing it again updates its event rather than adding another. +func EventID(weekStart, date store.LocalDate) string { + sum := sha256.Sum256([]byte("mealquest|" + weekStart.String() + "|" + date.String())) + return eventIDEncoding.EncodeToString(sum[:]) +} + +// pushDay is a day of a plan being pushed: its date and its event. +type pushDay struct { + date string + event gcal.Event +} + +// Push pushes the plan of the week starting weekStart to the household's +// chosen calendar and returns the plan as synced. Each day is an event +// "Dinner: {name}" from the household's dinner time on that date, in the +// household's zone, for an hour; its description is the dinner's name, +// "Start by {time}" for a crock-pot dinner whose minutes are known, and +// the dinner's link under the public URL. Its id is EventID. +// +// A push writes the days Calendar does not show as planned: those never +// pushed, swapped, or redrafted to another dinner since, whose +// calendar_event_id is empty. When every day has one, Calendar shows the +// whole plan and every day is written again, which puts back anything +// changed in Calendar since: the app is the source of truth. +// +// Only a push that wrote every one of those events is recorded: the plan +// becomes synced with calendar_synced_at now, and each day written stores +// its event id. A push that failed part way records nothing and leaves the +// plan's status as it was; since event ids are fixed, pushing again +// updates the events already written rather than adding them twice. A +// plan edited while it was pushed fails with ErrPlanChanged, unrecorded. +// +// The week must have a plan (else ErrNotFound). The calendar's errors are +// returned as they are: gcal.ErrNotConnected, gcal.ErrNoCalendar, +// gcal.ErrCalendarUnavailable, gcal.ErrReconnect or *gcal.CalendarError. +// +// A successful Push is the household confirming the week's plan, which is +// what the week's Supply run (Feature 17) is made from; the returned plan +// is the one pushed. +func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (Plan, error) { + if _, err := household.WeekDays(weekStart); err != nil { + return Plan{}, &InvalidWeekError{Value: weekStart.String()} + } + planID, revision, days, err := s.pushDays(ctx, weekStart) + if err != nil { + return Plan{}, err + } + events := make([]gcal.Event, len(days)) + for i, d := range days { + events[i] = d.event + } + if err := s.cal.PushEvents(ctx, events); err != nil { + return Plan{}, fmt.Errorf("plan: push %s to Calendar: %w", weekStart, err) + } + var p Plan + err = s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + now := store.Now().String() + n, err := q.MarkWeekPlanSynced(ctx, db.MarkWeekPlanSyncedParams{ + CalendarSyncedAt: sql.NullString{String: now, Valid: true}, + UpdatedAt: now, + ID: planID, + Revision: revision, + }) + if err != nil { + return fmt.Errorf("plan: mark %s synced: %w", planID, err) + } + if n != 1 { + return ErrPlanChanged + } + for _, d := range days { + n, err := q.SetPlanDayCalendarEvent(ctx, db.SetPlanDayCalendarEventParams{ + CalendarEventID: sql.NullString{String: d.event.ID, Valid: true}, + UpdatedAt: now, + PlanID: planID, + Date: d.date, + }) + if err != nil { + return fmt.Errorf("plan: record event of %s of %s: %w", d.date, planID, err) + } + if n != 1 { + return fmt.Errorf("plan: %s has no day %s", planID, d.date) + } + } + p, err = readPlan(ctx, q, weekStart) + return err + }) + if err != nil { + return Plan{}, err + } + return p, nil +} + +// pushDays reads, in one read transaction, the plan of the week starting +// weekStart, its revision, and the days a push writes with their events. +func (s *Service) pushDays(ctx context.Context, weekStart store.LocalDate) (planID string, revision int64, days []pushDay, err error) { + tx, err := s.st.Reader.BeginTx(ctx, nil) + if err != nil { + return "", 0, nil, fmt.Errorf("plan: begin read: %w", err) + } + defer func() { + err = errors.Join(err, tx.Rollback()) + }() + q := db.New(tx) + row, err := q.GetWeekPlan(ctx, weekStart.String()) + if errors.Is(err, sql.ErrNoRows) { + return "", 0, nil, ErrNotFound + } + if err != nil { + return "", 0, nil, fmt.Errorf("plan: read week %s: %w", weekStart, err) + } + settings, err := q.GetHouseholdSettings(ctx) + if err != nil { + return "", 0, nil, fmt.Errorf("plan: read household settings: %w", err) + } + zone, err := household.LoadZone(settings.Timezone) + if err != nil { + return "", 0, nil, fmt.Errorf("plan: household settings: %w", err) + } + rows, err := q.ListPlanDays(ctx, row.ID) + if err != nil { + return "", 0, nil, fmt.Errorf("plan: read days of %s: %w", row.ID, err) + } + dates, err := household.WeekDays(weekStart) + if err != nil { + return "", 0, nil, fmt.Errorf("plan: %w", err) + } + if len(rows) != len(dates) { + return "", 0, nil, fmt.Errorf("plan: week %s has %d planned days, want %d", weekStart, len(rows), len(dates)) + } + all := make([]pushDay, len(rows)) + for i, r := range rows { + if r.Date != dates[i].String() { + return "", 0, nil, fmt.Errorf("plan: week %s day %d is %s, want %s", weekStart, i+1, r.Date, dates[i]) + } + event, err := s.event(zone, settings.Timezone, settings.DinnerTime, weekStart, dates[i], r.Dinner) + if err != nil { + return "", 0, nil, fmt.Errorf("plan: event of %s: %w", r.Date, err) + } + d := pushDay{date: r.Date, event: event} + all[i] = d + if !r.CalendarEventID.Valid { + days = append(days, d) + } + } + if len(days) == 0 { + days = all + } + return row.ID, row.Revision, days, nil +} + +// event is the Calendar event of dinner d on date of the week starting +// weekStart, at dinnerTime in zone, named zoneName. +func (s *Service) event(zone *time.Location, zoneName, dinnerTime string, weekStart, date store.LocalDate, d db.Dinner) (gcal.Event, error) { + start, err := household.LocalTime(zone, date, dinnerTime) + if err != nil { + return gcal.Event{}, err + } + lines := []string{d.Name} + startBy, err := startBy(zone, date, dinnerTime, d) + if err != nil { + return gcal.Event{}, err + } + if startBy != nil { + lines = append(lines, "Start by "+startBy.In(zone).Format("3:04 pm")) + } + lines = append(lines, s.publicURL.JoinPath("dinner", d.ID).String()) + return gcal.Event{ + ID: EventID(weekStart, date), + Summary: "Dinner: " + d.Name, + Description: strings.Join(lines, "\n"), + Start: start, + End: start.Add(eventLength), + TimeZone: zoneName, + }, nil +} diff --git a/internal/plan/push_test.go b/internal/plan/push_test.go new file mode 100644 index 0000000..e3557e9 --- /dev/null +++ b/internal/plan/push_test.go @@ -0,0 +1,479 @@ +package plan_test + +import ( + "crypto/sha256" + "database/sql" + "encoding/base32" + "errors" + "maps" + "net/http" + "regexp" + "strings" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// dinnersCal is the calendar the tests push to. +var dinnersCal = gcaltest.Calendar{ID: "dinners@group.calendar.google.com", Summary: "Dinners", AccessRole: "owner"} + +// connectCalendar connects the household's Google account and chooses +// dinnersCal. +func (e *env) connectCalendar(t *testing.T) { + t.Helper() + e.google.SetCalendars(0, dinnersCal) + e.google.Connect(t, e.gcal) + if _, err := e.gcal.SelectCalendar(t.Context(), dinnersCal.ID); err != nil { + t.Fatal(err) + } +} + +// storePlan stores a draft plan of the week starting weekStart with the +// dinners named names, Monday first, and returns its days' dates. +func (e *env) storePlan(t *testing.T, weekStart store.LocalDate, names ...string) []string { + t.Helper() + q := db.New(e.st.Writer) + now := store.Now().String() + id := store.NewID() + if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: weekStart.String(), CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + y, m, d := weekStart.Date() + var dates []string + for i, name := range names { + date := store.LocalDateOf(time.Date(y, m, d+i, 0, 0, 0, 0, time.UTC)).String() + if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: date, + DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + dates = append(dates, date) + } + return dates +} + +// eventIDs returns each day's stored calendar_event_id of the week +// starting weekStart, "" for none, by date. +func (e *env) eventIDs(t *testing.T, weekStart store.LocalDate) map[string]string { + t.Helper() + rows, err := e.st.Reader.QueryContext(t.Context(), `SELECT plan_days.date, plan_days.calendar_event_id FROM plan_days + JOIN week_plans ON week_plans.id = plan_days.plan_id WHERE week_plans.week_start = ?`, weekStart.String()) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + out := map[string]string{} + for rows.Next() { + var date string + var id sql.NullString + if err := rows.Scan(&date, &id); err != nil { + t.Fatal(err) + } + out[date] = id.String + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out +} + +// assertUnsynced fails unless the plan of weekStart has status, no +// calendar_synced_at and no stored event ids. +func (e *env) assertUnsynced(t *testing.T, weekStart store.LocalDate, status string) { + t.Helper() + p, err := e.svc.Get(t.Context(), weekStart) + if err != nil { + t.Fatal(err) + } + if p.Status != status || p.CalendarSyncedAt != nil { + t.Errorf("plan = %s synced at %v, want %s and never synced", p.Status, p.CalendarSyncedAt, status) + } + for date, id := range e.eventIDs(t, weekStart) { + if id != "" { + t.Errorf("%s stored event id %q after a failed push", date, id) + } + } +} + +// assertNoSecret fails when text holds the refresh token, the access token +// or the client secret. +func assertNoSecret(t *testing.T, what, text string) { + t.Helper() + for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} { + if strings.Contains(text, secret) { + t.Errorf("%s contains a secret: %s", what, text) + } + } +} + +func TestEventIDIsDeterministicAndWithinGoogleRules(t *testing.T) { + monday, tuesday := mustDate("2026-10-12"), mustDate("2026-10-13") + id := plan.EventID(monday, tuesday) + sum := sha256.Sum256([]byte("mealquest|2026-10-12|2026-10-13")) + if want := strings.ToLower(base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString(sum[:])); id != want { + t.Errorf("EventID = %s, want %s", id, want) + } + // Google: base32hex characters (a-v, 0-9), 5 to 1024 of them. + if !regexp.MustCompile(`^[a-v0-9]{5,1000}$`).MatchString(id) || len(id) != 52 { + t.Errorf("EventID = %q (%d characters), want 52 of a-v and 0-9", id, len(id)) + } + if again := plan.EventID(monday, tuesday); again != id { + t.Errorf("EventID changed between calls: %s, %s", id, again) + } + if plan.EventID(monday, monday) == id || plan.EventID(mustDate("2026-10-19"), tuesday) == id { + t.Error("EventID is the same for another date or week") + } +} + +func TestPushCreatesOneEventPerDay(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + dates := e.storePlan(t, week, fullWeek...) + + p, err := e.svc.Push(t.Context(), week) + if err != nil { + t.Fatalf("Push: %v", err) + } + if p.Status != plan.StatusSynced || p.CalendarSyncedAt == nil { + t.Errorf("pushed plan = %s synced at %v, want synced with a time", p.Status, p.CalendarSyncedAt) + } + if inserts, updates := e.google.EventWrites(); inserts != 7 || updates != 0 { + t.Errorf("Google saw %d inserts and %d updates, want 7 and 0", inserts, updates) + } + events := e.google.Events(dinnersCal.ID) + if len(events) != 7 { + t.Fatalf("calendar has %d events, want 7", len(events)) + } + stored := e.eventIDs(t, week) + for i, date := range dates { + id := plan.EventID(week, mustDate(date)) + ev, ok := events[id] + if !ok { + t.Errorf("%s: no event %s", date, id) + continue + } + if stored[date] != id { + t.Errorf("%s stored event id %q, want %s", date, stored[date], id) + } + name := fullWeek[i] + if ev.Summary != "Dinner: "+name || ev.Status != "confirmed" { + t.Errorf("%s event = %q %s, want \"Dinner: %s\" confirmed", date, ev.Summary, ev.Status, name) + } + // Dinner time 17:30 in New York, EDT all week, for an hour. + if want := (gcaltest.EventTime{DateTime: date + "T17:30:00-04:00", TimeZone: "America/New_York"}); ev.Start != want { + t.Errorf("%s start = %+v, want %+v", date, ev.Start, want) + } + if want := (gcaltest.EventTime{DateTime: date + "T18:30:00-04:00", TimeZone: "America/New_York"}); ev.End != want { + t.Errorf("%s end = %+v, want %+v", date, ev.End, want) + } + } + // Beef Chili cooks 240 minutes in the crock pot; Pot Roast's minutes + // are unknown, so it has no start time, and Pad Thai is not a crock-pot + // dinner. + for _, tc := range []struct{ date, want string }{ + {"2026-10-12", "Beef Chili\nStart by 1:30 pm\nhttps://meals.example.com/dinner/" + e.ids["Beef Chili"]}, + {"2026-10-13", "Pot Roast\nhttps://meals.example.com/dinner/" + e.ids["Pot Roast"]}, + {"2026-10-14", "Pad Thai\nhttps://meals.example.com/dinner/" + e.ids["Pad Thai"]}, + } { + if got := events[plan.EventID(week, mustDate(tc.date))].Description; got != tc.want { + t.Errorf("%s description = %q, want %q", tc.date, got, tc.want) + } + } + got, err := e.svc.Get(t.Context(), week) + if err != nil { + t.Fatal(err) + } + if got.Status != plan.StatusSynced || got.CalendarSyncedAt == nil || *got.CalendarSyncedAt != *p.CalendarSyncedAt { + t.Errorf("stored plan = %s synced at %v, want as pushed", got.Status, got.CalendarSyncedAt) + } +} + +func TestPushAgainUpdatesOnly(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + e.storePlan(t, week, fullWeek...) + if _, err := e.svc.Push(t.Context(), week); err != nil { + t.Fatal(err) + } + // The household deletes Monday's event in Google Calendar; Google keeps + // its id. + monday := plan.EventID(week, week) + e.google.CancelEvent(dinnersCal.ID, monday) + e.google.ResetEventWrites() + + p, err := e.svc.Push(t.Context(), week) + if err != nil { + t.Fatalf("second Push: %v", err) + } + if inserts, updates := e.google.EventWrites(); inserts != 0 || updates != 7 { + t.Errorf("second push: %d inserts and %d updates, want 0 and 7", inserts, updates) + } + events := e.google.Events(dinnersCal.ID) + if len(events) != 7 { + t.Errorf("calendar has %d events, want 7", len(events)) + } + if events[monday].Status != "confirmed" { + t.Errorf("Monday's event is %s, want it put back", events[monday].Status) + } + if p.Status != plan.StatusSynced { + t.Errorf("status = %s, want synced", p.Status) + } +} + +func TestSwapThenPushUpdatesThatDay(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + e.storePlan(t, week, fullWeek...) + if _, err := e.svc.Push(t.Context(), week); err != nil { + t.Fatal(err) + } + before := e.google.Events(dinnersCal.ID) + e.google.ResetEventWrites() + + fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25) + swapped, err := e.svc.Swap(t.Context(), week, "2026-10-14", fishTacos) + if err != nil { + t.Fatal(err) + } + if swapped.Status != plan.StatusChanged { + t.Fatalf("status after swap = %s, want changed", swapped.Status) + } + if id := e.eventIDs(t, week)["2026-10-14"]; id != "" { + t.Errorf("swapped day kept event id %q", id) + } + + p, err := e.svc.Push(t.Context(), week) + if err != nil { + t.Fatalf("Push: %v", err) + } + if inserts, updates := e.google.EventWrites(); inserts != 0 || updates != 1 { + t.Errorf("push after swap: %d inserts and %d updates, want 0 and 1", inserts, updates) + } + wednesday := plan.EventID(week, mustDate("2026-10-14")) + after := e.google.Events(dinnersCal.ID) + if got := after[wednesday]; got.Summary != "Dinner: Fish Tacos" || + got.Description != "Fish Tacos\nhttps://meals.example.com/dinner/"+fishTacos { + t.Errorf("Wednesday's event = %+v, want Fish Tacos", got) + } + delete(before, wednesday) + delete(after, wednesday) + if !maps.Equal(before, after) { + t.Errorf("events of other days changed:\nbefore %+v\nafter %+v", before, after) + } + if p.Status != plan.StatusSynced { + t.Errorf("status = %s, want synced", p.Status) + } + if id := e.eventIDs(t, week)["2026-10-14"]; id != wednesday { + t.Errorf("Wednesday stored event id %q, want %s", id, wednesday) + } +} + +func TestPushAcrossDaylightSavingTime(t *testing.T) { + for _, tc := range []struct { + name string + week store.LocalDate + // utc is each day's 17:30 New York dinner as a UTC instant. + utc []string + }{ + // New York leaves daylight saving time at 02:00 on Sunday + // 2026-11-01. + {"fall back", mustDate("2026-10-26"), []string{ + "2026-10-26T21:30:00Z", "2026-10-27T21:30:00Z", "2026-10-28T21:30:00Z", "2026-10-29T21:30:00Z", + "2026-10-30T21:30:00Z", "2026-10-31T21:30:00Z", "2026-11-01T22:30:00Z", + }}, + // It enters it at 02:00 on Sunday 2027-03-14. + {"spring forward", mustDate("2027-03-08"), []string{ + "2027-03-08T22:30:00Z", "2027-03-09T22:30:00Z", "2027-03-10T22:30:00Z", "2027-03-11T22:30:00Z", + "2027-03-12T22:30:00Z", "2027-03-13T22:30:00Z", "2027-03-14T21:30:00Z", + }}, + } { + t.Run(tc.name, func(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + dates := e.storePlan(t, tc.week, fullWeek...) + if _, err := e.svc.Push(t.Context(), tc.week); err != nil { + t.Fatalf("Push: %v", err) + } + events := e.google.Events(dinnersCal.ID) + for i, date := range dates { + ev := events[plan.EventID(tc.week, mustDate(date))] + start, err := time.Parse(time.RFC3339, ev.Start.DateTime) + if err != nil { + t.Fatalf("%s start %q: %v", date, ev.Start.DateTime, err) + } + end, err := time.Parse(time.RFC3339, ev.End.DateTime) + if err != nil { + t.Fatalf("%s end %q: %v", date, ev.End.DateTime, err) + } + if got := start.UTC().Format(time.RFC3339); got != tc.utc[i] { + t.Errorf("%s starts %s (%s), want %s", date, got, ev.Start.DateTime, tc.utc[i]) + } + if !strings.HasPrefix(ev.Start.DateTime, date+"T17:30:00") { + t.Errorf("%s starts %s, want 17:30 local", date, ev.Start.DateTime) + } + if end.Sub(start) != time.Hour { + t.Errorf("%s lasts %v, want 1h", date, end.Sub(start)) + } + if ev.Start.TimeZone != "America/New_York" || ev.End.TimeZone != "America/New_York" { + t.Errorf("%s zones = %s, %s", date, ev.Start.TimeZone, ev.End.TimeZone) + } + } + }) + } +} + +func TestPushNeedsAChosenCalendar(t *testing.T) { + e := newEnv(t) + e.storePlan(t, week, fullWeek...) + if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNotConnected) { + t.Errorf("Push with nothing connected = %v, want ErrNotConnected", err) + } + e.google.SetCalendars(0, dinnersCal) + e.google.Connect(t, e.gcal) + if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) { + t.Errorf("Push with no calendar chosen = %v, want ErrNoCalendar", err) + } + if inserts, updates := e.google.EventWrites(); inserts+updates != 0 { + t.Errorf("Google saw %d event writes", inserts+updates) + } + e.assertUnsynced(t, week, plan.StatusDraft) +} + +func TestPushWithoutAPlan(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + before := e.google.TokenCalls() + if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, plan.ErrNotFound) { + t.Errorf("Push = %v, want ErrNotFound", err) + } + if n := e.google.TokenCalls() - before; n != 0 { + t.Errorf("token endpoint called %d times for a week with no plan", n) + } +} + +func TestPushPartialFailureLeavesPlanUnsynced(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + e.storePlan(t, week, fullWeek...) + // Google fails the fourth event. + e.google.FailEventWrite(4, http.StatusServiceUnavailable) + + _, err := e.svc.Push(t.Context(), week) + var calendar *gcal.CalendarError + if !errors.As(err, &calendar) { + t.Fatalf("Push = %v, want *gcal.CalendarError", err) + } + assertNoSecret(t, "error", err.Error()) + if n := len(e.google.Events(dinnersCal.ID)); n != 3 { + t.Fatalf("calendar has %d events, want the 3 before the failure", n) + } + e.assertUnsynced(t, week, plan.StatusDraft) + + // Pushing again writes every day: the three already there are updated, + // not added twice. + e.google.ResetEventWrites() + p, err := e.svc.Push(t.Context(), week) + if err != nil { + t.Fatalf("retried Push: %v", err) + } + if inserts, updates := e.google.EventWrites(); inserts != 4 || updates != 3 { + t.Errorf("retry: %d inserts and %d updates, want 4 and 3", inserts, updates) + } + if n := len(e.google.Events(dinnersCal.ID)); n != 7 || p.Status != plan.StatusSynced { + t.Errorf("after retry: %d events, plan %s; want 7, synced", n, p.Status) + } +} + +func TestPushPartialFailureKeepsAChangedPlanChanged(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + e.storePlan(t, week, fullWeek...) + first, err := e.svc.Push(t.Context(), week) + if err != nil { + t.Fatal(err) + } + for date, name := range map[string]string{"2026-10-12": "Fish Tacos", "2026-10-13": "Ramen"} { + if _, err := e.svc.Swap(t.Context(), week, date, e.dinner(t, name, "regular", "stovetop", 25)); err != nil { + t.Fatal(err) + } + } + e.google.FailEventWrite(2, http.StatusInternalServerError) + if _, err := e.svc.Push(t.Context(), week); err == nil { + t.Fatal("Push succeeded, want the second event to fail") + } + p, err := e.svc.Get(t.Context(), week) + if err != nil { + t.Fatal(err) + } + if p.Status != plan.StatusChanged || *p.CalendarSyncedAt != *first.CalendarSyncedAt { + t.Errorf("plan = %s synced at %v, want changed, synced at %s", p.Status, *p.CalendarSyncedAt, *first.CalendarSyncedAt) + } + ids := e.eventIDs(t, week) + if ids["2026-10-12"] != "" || ids["2026-10-13"] != "" { + t.Errorf("swapped days stored event ids %v after a failed push", ids) + } +} + +func TestPushToStaleCalendar(t *testing.T) { + for name, edit := range map[string]func(*gcaltest.Google){ + "deleted": func(g *gcaltest.Google) { g.SetCalendars(0) }, + "read-only": func(g *gcaltest.Google) { + g.SetCalendars(0, gcaltest.Calendar{ID: dinnersCal.ID, Summary: "Dinners", AccessRole: "reader"}) + }, + } { + t.Run(name, func(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + e.storePlan(t, week, fullWeek...) + edit(e.google) + if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrCalendarUnavailable) { + t.Errorf("Push = %v, want ErrCalendarUnavailable", err) + } + e.assertUnsynced(t, week, plan.StatusDraft) + }) + } +} + +func TestPushGrantNoLongerAccepted(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + e.storePlan(t, week, fullWeek...) + e.google.RevokeGrant() + _, err := e.svc.Push(t.Context(), week) + if !errors.Is(err, gcal.ErrReconnect) { + t.Fatalf("Push = %v, want ErrReconnect", err) + } + assertNoSecret(t, "error", err.Error()) + e.assertUnsynced(t, week, plan.StatusDraft) + // The connection is left as it is: forgetting it is not the push's to + // decide. + if _, err := db.New(e.st.Reader).GetIntegrationsGoogle(t.Context()); err != nil { + t.Errorf("connection after a refused grant: %v", err) + } +} + +func TestPushFailsWhenPlanSwappedWhilePushing(t *testing.T) { + e := newEnv(t) + e.connectCalendar(t) + e.storePlan(t, week, fullWeek...) + arrived, release := e.google.HoldEvents() + defer release() + pushed := make(chan error, 1) + go func() { + _, err := e.svc.Push(t.Context(), week) + pushed <- err + }() + <-arrived + if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)); err != nil { + t.Fatal(err) + } + release() + if err := <-pushed; !errors.Is(err, plan.ErrPlanChanged) { + t.Fatalf("Push = %v, want ErrPlanChanged", err) + } + e.assertUnsynced(t, week, plan.StatusDraft) +} diff --git a/internal/server/plans.go b/internal/server/plans.go index b15d345..5aa29e3 100644 --- a/internal/server/plans.go +++ b/internal/server/plans.go @@ -7,6 +7,7 @@ import ( "github.com/go-chi/chi/v5" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" ) @@ -93,6 +94,58 @@ func putPlanDay(logger *slog.Logger, svc *plan.Service) http.HandlerFunc { } } +// postPlanPush pushes the week's plan to the chosen Google calendar +// (Push to Calendar) and answers with the plan as synced. +func postPlanPush(logger *slog.Logger, svc *plan.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + week, ok := weekStartOrFail(w, r, logger) + if !ok { + return + } + p, err := svc.Push(r.Context(), week) + if err != nil { + writePlanPushError(w, r, logger, err) + return + } + writeJSON(w, r, logger, http.StatusOK, p) + } +} + +// writePlanPushError maps a Push to Calendar error to its API error: 409 +// not_connected when Google Calendar is not connected, no_calendar when no +// calendar is chosen, calendar_unavailable when the chosen calendar was +// deleted or is no longer writable, and plan_changed when the plan was +// edited during the push; 502 reconnect_required when Google no longer +// accepts the grant, and google_failed when Google refused or could not be +// reached. A failed push leaves the plan's status as it was. Any other +// error is the plan's, as writePlanError maps it. +func writePlanPushError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, err error) { + var calendar *gcal.CalendarError + switch { + case errors.Is(err, gcal.ErrNotConnected): + writeError(w, r, logger, http.StatusConflict, "not_connected", "Google Calendar is not connected") + case errors.Is(err, gcal.ErrNoCalendar): + writeError(w, r, logger, http.StatusConflict, "no_calendar", + "choose the calendar to push dinners to in Settings, then push again") + case errors.Is(err, gcal.ErrCalendarUnavailable): + logger.WarnContext(r.Context(), "push plan to Calendar", "error", err) + writeError(w, r, logger, http.StatusConflict, "calendar_unavailable", + "the chosen calendar was deleted or MealQuest can no longer add dinners to it; choose another calendar in Settings") + case errors.Is(err, plan.ErrPlanChanged): + writeError(w, r, logger, http.StatusConflict, "plan_changed", err.Error()) + case errors.Is(err, gcal.ErrReconnect): + logger.WarnContext(r.Context(), "push plan to Calendar", "error", err) + writeError(w, r, logger, http.StatusBadGateway, "reconnect_required", + "Google no longer accepts MealQuest's access to your calendar; connect Google Calendar again") + case errors.As(err, &calendar): + logger.WarnContext(r.Context(), "push plan to Calendar", "error", err) + writeError(w, r, logger, http.StatusBadGateway, "google_failed", + "Google Calendar could not be reached; push again") + default: + writePlanError(w, r, logger, "push plan to Calendar", err) + } +} + // weekStartOrFail parses the {weekStart} path parameter, or answers 400 // when it is not a Monday date. func weekStartOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (store.LocalDate, bool) { diff --git a/internal/server/plans_test.go b/internal/server/plans_test.go index b6e131a..6e61c8d 100644 --- a/internal/server/plans_test.go +++ b/internal/server/plans_test.go @@ -1,14 +1,15 @@ package server import ( + "bytes" "context" "database/sql" "encoding/json" "errors" - "io" "log/slog" "net/http" "net/http/httptest" + "net/url" "strings" "sync" "testing" @@ -17,6 +18,8 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" @@ -34,6 +37,12 @@ type plansEnv struct { st *store.Store svc *plan.Service questLog string + // google is the fake Google the plan service pushes to; it starts + // unconnected. + google *gcaltest.Google + gcal *gcal.Service + // logs holds every log line the router writes. + logs *bytes.Buffer } func newPlansRouter(t *testing.T) plansEnv { @@ -43,7 +52,8 @@ func newPlansRouter(t *testing.T) plansEnv { if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil { t.Fatal(err) } - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + logs := &bytes.Buffer{} + logger := slog.New(slog.NewJSONHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug})) runner, err := jobs.NewRunner(st, logger, 1) if err != nil { t.Fatal(err) @@ -51,12 +61,26 @@ func newPlansRouter(t *testing.T) plansEnv { fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { return skills.Week{}, errors.New("no model in this test") } - svc, err := plan.NewService(st, fail, runner, logger) + pub := &url.URL{Scheme: "https", Host: "mealquest.example"} + google := gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath) + googleSvc, err := gcal.NewService(st, newTestBox(t), gcal.Config{ + ClientID: gcaltest.ClientID, + ClientSecret: gcaltest.ClientSecret, + PublicURL: pub, + SecretKey: []byte("server-secret-key"), + Endpoint: google.Endpoint(), + RevokeURL: google.RevokeURL(), + CalendarAPI: google.CalendarAPI(), + }) + if err != nil { + t.Fatal(err) + } + svc, err := plan.NewService(st, fail, runner, googleSvc, pub, logger) if err != nil { t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - newTestDinners(t), newTestSuggest(t), svc, newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinners(t), newTestSuggest(t), svc, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } @@ -90,7 +114,8 @@ func newPlansRouter(t *testing.T) plansEnv { t.Fatal(err) } } - return plansEnv{h: h, st: st, svc: svc, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35)} + return plansEnv{h: h, st: st, svc: svc, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35), + google: google, gcal: googleSvc, logs: logs} } func (e plansEnv) setStatus(t *testing.T, status string) { @@ -324,3 +349,141 @@ func TestPutPlanDay(t *testing.T) { t.Errorf("PUT archived dinner = %d %v, want 409 dinner_not_plannable", rec.Code, body) } } + +// pushCalendar is the calendar the push tests choose. +var pushCalendar = gcaltest.Calendar{ID: "dinners@group.calendar.google.com", Summary: "Dinners", AccessRole: "owner"} + +// connectCalendar connects the fake Google and, when choose, chooses +// pushCalendar. +func (e plansEnv) connectCalendar(t *testing.T, choose bool) { + t.Helper() + e.google.SetCalendars(0, pushCalendar) + e.google.Connect(t, e.gcal) + if !choose { + return + } + if _, err := e.gcal.SelectCalendar(t.Context(), pushCalendar.ID); err != nil { + t.Fatal(err) + } +} + +// push calls POST /plans/{weekStart}/push-calendar and checks that neither +// the response nor any log line holds a secret. +func (e plansEnv) push(t *testing.T, weekStart string) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + rec, body := callDinners(t, e.h, http.MethodPost, plansPath+weekStart+"/push-calendar", "") + for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} { + if strings.Contains(rec.Body.String(), secret) { + t.Errorf("response holds a secret: %s", rec.Body) + } + if strings.Contains(e.logs.String(), secret) { + t.Errorf("logs hold a secret: %s", e.logs) + } + } + return rec, body +} + +func TestPushPlanToCalendar(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.connectCalendar(t, true) + + rec, body := e.push(t, "2026-10-12") + if rec.Code != http.StatusOK { + t.Fatalf("push = %d: %s", rec.Code, rec.Body) + } + if body["status"] != "synced" || body["calendar_synced_at"] == nil { + t.Errorf("pushed plan = %v, want synced", body) + } + if days, _ := body["days"].([]any); len(days) != 7 { + t.Errorf("days = %v, want 7", body["days"]) + } + if n := len(e.google.Events(pushCalendar.ID)); n != 7 { + t.Errorf("calendar has %d events, want 7", n) + } + monday := e.google.Events(pushCalendar.ID)[plan.EventID(mustLocalDate(t, "2026-10-12"), mustLocalDate(t, "2026-10-12"))] + if !strings.HasPrefix(monday.Description, "Beef Chili\nStart by 1:30 pm\nhttps://mealquest.example/dinner/") { + t.Errorf("Monday's description = %q", monday.Description) + } +} + +func TestPushPlanToCalendarErrors(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + week string + setup func(t *testing.T, e plansEnv) + status int + code string + }{ + {"not connected", "2026-10-12", func(*testing.T, plansEnv) {}, http.StatusConflict, "not_connected"}, + {"no calendar chosen", "2026-10-12", func(t *testing.T, e plansEnv) { e.connectCalendar(t, false) }, http.StatusConflict, "no_calendar"}, + {"calendar deleted", "2026-10-12", func(t *testing.T, e plansEnv) { + e.connectCalendar(t, true) + e.google.SetCalendars(0) + }, http.StatusConflict, "calendar_unavailable"}, + {"calendar read-only", "2026-10-12", func(t *testing.T, e plansEnv) { + e.connectCalendar(t, true) + e.google.SetCalendars(0, gcaltest.Calendar{ID: pushCalendar.ID, Summary: "Dinners", AccessRole: "reader"}) + }, http.StatusConflict, "calendar_unavailable"}, + {"grant revoked", "2026-10-12", func(t *testing.T, e plansEnv) { + e.connectCalendar(t, true) + e.google.RevokeGrant() + }, http.StatusBadGateway, "reconnect_required"}, + {"Google fails part way", "2026-10-12", func(t *testing.T, e plansEnv) { + e.connectCalendar(t, true) + e.google.FailEventWrite(3, http.StatusServiceUnavailable) + }, http.StatusBadGateway, "google_failed"}, + {"no plan", "2026-10-19", func(t *testing.T, e plansEnv) { e.connectCalendar(t, true) }, http.StatusNotFound, "not_found"}, + {"not a Monday", "2026-10-13", func(t *testing.T, e plansEnv) { e.connectCalendar(t, true) }, http.StatusBadRequest, "invalid_week"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + tc.setup(t, e) + rec, body := e.push(t, tc.week) + if rec.Code != tc.status || errorCode(body) != tc.code { + t.Errorf("push = %d %s, want %d %s", rec.Code, rec.Body, tc.status, tc.code) + } + p, err := e.svc.Get(t.Context(), mustLocalDate(t, "2026-10-12")) + if err != nil { + t.Fatal(err) + } + if p.Status != plan.StatusDraft || p.CalendarSyncedAt != nil { + t.Errorf("plan = %s synced at %v after a failed push, want draft", p.Status, p.CalendarSyncedAt) + } + }) + } +} + +func TestPushPlanChangedWhilePushing(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.connectCalendar(t, true) + arrived, release := e.google.HoldEvents() + defer release() + pushed := make(chan *httptest.ResponseRecorder, 1) + go func() { + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, signedIn(http.MethodPost, plansPath+"2026-10-12/push-calendar", nil)) + pushed <- rec + }() + <-arrived + if _, err := e.svc.Swap(t.Context(), mustLocalDate(t, "2026-10-12"), "2026-10-13", e.questLog); err != nil { + t.Fatal(err) + } + release() + rec := <-pushed + if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), `"code":"plan_changed"`) { + t.Errorf("push = %d %s, want 409 plan_changed", rec.Code, rec.Body) + } +} + +func mustLocalDate(t *testing.T, s string) store.LocalDate { + t.Helper() + d, err := store.ParseLocalDate(s) + if err != nil { + t.Fatal(err) + } + return d +} diff --git a/internal/server/server.go b/internal/server/server.go index d6ba037..336d4f0 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -133,6 +133,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Get("/plans/{weekStart}", getPlan(logger, planSvc)) apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc)) apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc)) + apiV1.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc)) apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc)) apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc)) // connect and callback are top-level browser navigations, so they are diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 38c8a3e..4ede01b 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -202,7 +202,7 @@ func newTestPlan(t *testing.T) *plan.Service { fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { return skills.Week{}, errors.New("no model in this test") } - svc, err := plan.NewService(st, fail, runner, logger) + svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger) if err != nil { t.Fatal(err) } diff --git a/internal/suggest/suggest_test.go b/internal/suggest/suggest_test.go index 469579a..eae7111 100644 --- a/internal/suggest/suggest_test.go +++ b/internal/suggest/suggest_test.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "log/slog" + "net/url" "slices" "strings" "sync" @@ -19,6 +20,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" @@ -927,7 +929,7 @@ func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) { fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { return skills.Week{}, errors.New("no draft in this test") } - planSvc, err := plan.NewService(e.st, fail, e.runner, logger) + planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger) if err != nil { t.Fatal(err) } @@ -1320,3 +1322,10 @@ func TestStartBatchRefusesUnknownTrigger(t *testing.T) { t.Fatal("unknown trigger accepted") } } + +// noCalendar is a plan.Calendar for tests that never push. +type noCalendar struct{} + +func (noCalendar) PushEvents(context.Context, []gcal.Event) error { + return errors.New("no calendar in this test") +} diff --git a/migrations/queries/plans.sql b/migrations/queries/plans.sql index c64d7f0..4e61d17 100644 --- a/migrations/queries/plans.sql +++ b/migrations/queries/plans.sql @@ -7,7 +7,7 @@ WHERE week_start = ?; -- name: ListPlanDays :many -- The days of plan plan_id with their dinners, Monday first. A day with -- no dinner is left out. -SELECT plan_days.date, plan_days.is_new, sqlc.embed(dinners) +SELECT plan_days.date, plan_days.is_new, plan_days.calendar_event_id, sqlc.embed(dinners) FROM plan_days JOIN dinners ON dinners.id = plan_days.dinner_id WHERE plan_days.plan_id = ? @@ -25,20 +25,39 @@ SET status = ?, ai_note = ?, revision = revision + 1, updated_at = ? WHERE id = ?; -- name: PutPlanDay :exec --- Plans dinner_id on date of plan_id, keeping the day's calendar event id --- when the day exists. +-- Plans dinner_id on date of plan_id. An existing day keeps its calendar +-- event id while its dinner stays the same; a new dinner clears it, since +-- Calendar no longer shows the day as planned. INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (plan_id, date) DO UPDATE -SET dinner_id = excluded.dinner_id, is_new = excluded.is_new, updated_at = excluded.updated_at; +SET dinner_id = excluded.dinner_id, is_new = excluded.is_new, + calendar_event_id = CASE WHEN plan_days.dinner_id IS excluded.dinner_id THEN plan_days.calendar_event_id END, + updated_at = excluded.updated_at; -- name: SwapPlanDay :execrows --- Plans dinner_id on an existing day of plan_id; a date the plan does not --- have is left as it is (0 rows). +-- Plans dinner_id on an existing day of plan_id, clearing its calendar +-- event id since Calendar no longer shows the day as planned; a date the +-- plan does not have is left as it is (0 rows). UPDATE plan_days -SET dinner_id = ?, is_new = ?, updated_at = ? +SET dinner_id = ?, is_new = ?, calendar_event_id = NULL, updated_at = ? WHERE plan_id = ? AND date = ?; +-- name: SetPlanDayCalendarEvent :execrows +-- Records that the event calendar_event_id on Calendar shows date of +-- plan_id as planned. +UPDATE plan_days +SET calendar_event_id = ?, updated_at = ? +WHERE plan_id = ? AND date = ?; + +-- name: MarkWeekPlanSynced :execrows +-- Records a Push to Calendar of plan id as it was at revision: the plan +-- becomes synced. A plan edited since (another revision) is left as it is +-- (0 rows). +UPDATE week_plans +SET status = 'synced', calendar_synced_at = ?, updated_at = ? +WHERE id = ? AND revision = ?; + -- name: MarkWeekPlanEdited :exec -- Records an edit of plan id, raising its revision: a synced plan becomes -- changed, any other keeps its status.