Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
224 lines
8.4 KiB
Go
224 lines
8.4 KiB
Go
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, then runs
|
|
// record while that calendar stays chosen. In production it is
|
|
// *gcal.Service.
|
|
type Calendar interface {
|
|
PushEvents(ctx context.Context, events []gcal.Event, record func() error) 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. A stored event
|
|
// id is always on the chosen calendar: choosing another
|
|
// (gcal.Service.SelectCalendar) or disconnecting (gcal.Service.Disconnect)
|
|
// clears them all, so the next push writes the whole week.
|
|
//
|
|
// 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. The
|
|
// push is recorded while the calendar written to is still the chosen one.
|
|
// A plan edited while it was pushed, or whose events were forgotten by
|
|
// choosing another calendar or disconnecting, 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, and revision is its revision as pushed, so the Supply
|
|
// run can be made from that plan and no later edit of it.
|
|
func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (p Plan, revision int64, err error) {
|
|
if _, err := household.WeekDays(weekStart); err != nil {
|
|
return Plan{}, 0, &InvalidWeekError{Value: weekStart.String()}
|
|
}
|
|
planID, revision, days, err := s.pushDays(ctx, weekStart)
|
|
if err != nil {
|
|
return Plan{}, 0, err
|
|
}
|
|
events := make([]gcal.Event, len(days))
|
|
for i, d := range days {
|
|
events[i] = d.event
|
|
}
|
|
record := func() error {
|
|
return 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 := s.cal.PushEvents(ctx, events, record); err != nil {
|
|
return Plan{}, 0, fmt.Errorf("plan: push %s to Calendar: %w", weekStart, err)
|
|
}
|
|
return p, revision, 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
|
|
}
|