Push plan to Google Calendar (Feature 16) (#127)
CI / ci (push) Successful in 3m56s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #127.
This commit is contained in:
2026-10-07 23:27:46 -04:00
committed by approver_agent
parent 482bec9727
commit 6262489376
20 changed files with 2227 additions and 90 deletions
+70 -19
View File
@@ -71,7 +71,8 @@ func (s *Service) Calendars(ctx context.Context) ([]Calendar, error) {
// and name. id must be among Calendars at the time of the call; anything
// else, including a calendar chosen earlier that has since been deleted or
// is no longer writable, is ErrUnknownCalendar and leaves the stored
// choice as it was.
// choice as it was. Choosing another calendar than the one chosen returns
// every plan pushed to that one to not synced; see saveSelection.
func (s *Service) SelectCalendar(ctx context.Context, id string) (Selection, error) {
s.account.Lock()
defer s.account.Unlock()
@@ -84,28 +85,73 @@ func (s *Service) SelectCalendar(ctx context.Context, id string) (Selection, err
continue
}
sel := Selection{CalendarID: c.ID, CalendarName: c.Summary}
n, err := db.New(s.st.Writer).SetIntegrationsGoogleCalendar(ctx, db.SetIntegrationsGoogleCalendarParams{
CalendarID: sql.NullString{String: sel.CalendarID, Valid: true},
CalendarName: sql.NullString{String: sel.CalendarName, Valid: true},
UpdatedAt: store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String(),
})
if err != nil {
return Selection{}, fmt.Errorf("gcal: save calendar: %w", err)
}
if n != 1 {
// The lock is held since the connection was read, so only a
// change outside this service gets here.
return Selection{}, ErrNotConnected
if err := s.saveSelection(ctx, sel); err != nil {
return Selection{}, err
}
return sel, nil
}
return Selection{}, ErrUnknownCalendar
}
// saveSelection stores sel as the chosen calendar. Choosing another calendar
// than the one stored also forgets every plan day's calendar event id, in
// the same transaction, since those events are on the calendar no longer
// chosen (forgetEvents): each plan with one is edited, so a synced plan
// becomes changed and its next push writes every day to sel. The caller
// holds s.account, which a push also holds from its event writes through
// recording them, so a push never records events written to another
// calendar than the chosen one.
func (s *Service) saveSelection(ctx context.Context, sel Selection) error {
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
row, err := q.GetIntegrationsGoogle(ctx)
if errors.Is(err, sql.ErrNoRows) {
// The lock is held since the connection was read, so only a
// change outside this service gets here.
return ErrNotConnected
}
if err != nil {
return fmt.Errorf("gcal: read Google integration: %w", err)
}
now := store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String()
if _, err := q.SetIntegrationsGoogleCalendar(ctx, db.SetIntegrationsGoogleCalendarParams{
CalendarID: sql.NullString{String: sel.CalendarID, Valid: true},
CalendarName: sql.NullString{String: sel.CalendarName, Valid: true},
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("gcal: save calendar: %w", err)
}
if row.CalendarID.Valid && row.CalendarID.String == sel.CalendarID {
return nil
}
return forgetEvents(ctx, q, now)
})
}
// forgetEvents records, in the caller's transaction q, that no calendar
// event id stored is on the chosen calendar any more: every plan with one is
// edited (a synced plan becomes changed, and a push that read it before is
// refused), then every id is cleared. Every change that ends the chosen
// calendar — choosing another (saveSelection) or disconnecting
// (Disconnect) — runs it in the transaction that makes the change.
func forgetEvents(ctx context.Context, q *db.Queries, now string) error {
if err := q.MarkCalendarPlansEdited(ctx, now); err != nil {
return fmt.Errorf("gcal: mark plans pushed to the earlier calendar edited: %w", err)
}
if err := q.ClearCalendarEvents(ctx, now); err != nil {
return fmt.Errorf("gcal: forget events on the earlier calendar: %w", err)
}
return nil
}
// 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 +182,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)