Feature 16: Google Calendar Sync (#130)
CI / ci (push) Successful in 4m51s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #130.
This commit is contained in:
2026-10-08 03:13:21 -04:00
committed by approver_agent
parent 5ac5982c1f
commit 7003ff92ec
65 changed files with 8092 additions and 222 deletions
+105 -10
View File
@@ -10,6 +10,18 @@ import (
"database/sql"
)
const clearCalendarEvents = `-- name: ClearCalendarEvents :exec
UPDATE plan_days
SET calendar_event_id = NULL, updated_at = ?
WHERE calendar_event_id IS NOT NULL
`
// Clears every day's calendar event id: none is on the chosen calendar.
func (q *Queries) ClearCalendarEvents(ctx context.Context, updatedAt string) error {
_, err := q.db.ExecContext(ctx, clearCalendarEvents, updatedAt)
return err
}
const createWeekPlan = `-- name: CreateWeekPlan :exec
INSERT INTO week_plans (id, week_start, status, ai_note, created_at, updated_at)
VALUES (?, ?, 'draft', ?, ?, ?)
@@ -154,7 +166,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 +174,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 +194,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,
@@ -247,6 +261,26 @@ func (q *Queries) ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) (
return items, nil
}
const markCalendarPlansEdited = `-- name: MarkCalendarPlansEdited :exec
UPDATE week_plans
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
revision = revision + 1,
updated_at = ?
WHERE EXISTS (
SELECT 1 FROM plan_days
WHERE plan_days.plan_id = week_plans.id AND plan_days.calendar_event_id IS NOT NULL
)
`
// Records that Calendar no longer shows the days with a calendar event id
// as planned, since another calendar or none is chosen: every plan with
// such a day is edited as MarkWeekPlanEdited does, so a synced plan becomes
// changed and a push that read it before is refused.
func (q *Queries) MarkCalendarPlansEdited(ctx context.Context, updatedAt string) error {
_, err := q.db.ExecContext(ctx, markCalendarPlansEdited, updatedAt)
return err
}
const markWeekPlanEdited = `-- name: MarkWeekPlanEdited :exec
UPDATE week_plans
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
@@ -267,11 +301,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 +349,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 +390,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 +432,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,
+27 -4
View File
@@ -15,6 +15,12 @@ type Querier interface {
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
// when no job is queued.
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
// Clears every day's calendar event id: none is on the chosen calendar.
ClearCalendarEvents(ctx context.Context, updatedAt string) error
// Records that Google refuses the stored grant: clears the refresh token and
// sets last_sync_error, keeping the chosen calendar. Affects no row when
// nothing is connected.
ClearIntegrationsGoogleToken(ctx context.Context, arg ClearIntegrationsGoogleTokenParams) (int64, error)
// Drops references to the runs created before the cutoff, so the purge
// can delete them.
ClearSuggestionBatchAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
@@ -50,6 +56,7 @@ type Querier interface {
// decided is left as it is (0 rows).
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
DeleteIntegrationsGoogle(ctx context.Context) error
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
DeleteRecipeSteps(ctx context.Context, recipeID string) error
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
@@ -169,12 +176,21 @@ type Querier interface {
// The ids of the dinners planned in the week starting week_start, Monday
// first.
ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) ([]sql.NullString, error)
// Records that Calendar no longer shows the days with a calendar event id
// as planned, since another calendar or none is chosen: every plan with
// such a day is edited as MarkWeekPlanEdited does, so a synced plan becomes
// changed and a push that read it before is refused.
MarkCalendarPlansEdited(ctx context.Context, updatedAt string) error
// Moves a dinner still in the Quest log to tried; a dinner with any other
// status is left as it is (0 rows).
MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error)
// 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).
@@ -185,20 +201,27 @@ 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.
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error
// An unknown id returns sql.ErrNoRows.
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)
+53
View File
@@ -10,6 +10,38 @@ import (
"database/sql"
)
const clearIntegrationsGoogleToken = `-- name: ClearIntegrationsGoogleToken :execrows
UPDATE integrations_google
SET refresh_token_ct = NULL, last_sync_error = ?, updated_at = ?
WHERE id = 1
`
type ClearIntegrationsGoogleTokenParams struct {
LastSyncError sql.NullString
UpdatedAt string
}
// Records that Google refuses the stored grant: clears the refresh token and
// sets last_sync_error, keeping the chosen calendar. Affects no row when
// nothing is connected.
func (q *Queries) ClearIntegrationsGoogleToken(ctx context.Context, arg ClearIntegrationsGoogleTokenParams) (int64, error) {
result, err := q.db.ExecContext(ctx, clearIntegrationsGoogleToken, arg.LastSyncError, arg.UpdatedAt)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const deleteIntegrationsGoogle = `-- name: DeleteIntegrationsGoogle :exec
DELETE FROM integrations_google
WHERE id = 1
`
func (q *Queries) DeleteIntegrationsGoogle(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, deleteIntegrationsGoogle)
return err
}
const getAISettings = `-- name: GetAISettings :one
SELECT id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at FROM ai_settings
WHERE id = 1
@@ -129,6 +161,27 @@ func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSett
return i, err
}
const setIntegrationsGoogleCalendar = `-- name: SetIntegrationsGoogleCalendar :execrows
UPDATE integrations_google
SET calendar_id = ?, calendar_name = ?, updated_at = ?
WHERE id = 1
`
type SetIntegrationsGoogleCalendarParams struct {
CalendarID sql.NullString
CalendarName sql.NullString
UpdatedAt string
}
// Sets the chosen calendar; affects no row when nothing is connected.
func (q *Queries) SetIntegrationsGoogleCalendar(ctx context.Context, arg SetIntegrationsGoogleCalendarParams) (int64, error) {
result, err := q.db.ExecContext(ctx, setIntegrationsGoogleCalendar, arg.CalendarID, arg.CalendarName, arg.UpdatedAt)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const upsertAISettings = `-- name: UpsertAISettings :one
INSERT INTO ai_settings (
id, provider, base_url, model, temperature, system_prompt, api_key_ct,